From f7b0e3cc8d0a7840bb39e5f778ed26d05d094df7 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Wed, 26 Aug 2026 08:34:52 +0200 Subject: [PATCH 1/8] feat(arch001): add IAdminDataScope implementations (Store/Routed) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013XGyrBT97Wr9zjxgDmG2KR --- .../RoutedPaymentTransactionDataScopeTests.cs | 68 +++++++++++++++++++ .../StorePaymentTransactionDataScopeTests.cs | 51 ++++++++++++++ .../RoutedPaymentTransactionDataScope.cs | 52 ++++++++++++++ .../StorePaymentTransactionDataScope.cs | 32 +++++++++ .../Startup/StartupApplication.cs | 8 +++ 5 files changed, 211 insertions(+) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedPaymentTransactionDataScopeTests.cs create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/StorePaymentTransactionDataScopeTests.cs create mode 100644 src/Web/Grand.Web.AdminShared/Services/RoutedPaymentTransactionDataScope.cs create mode 100644 src/Web/Grand.Web.AdminShared/Services/StorePaymentTransactionDataScope.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedPaymentTransactionDataScopeTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedPaymentTransactionDataScopeTests.cs new file mode 100644 index 000000000..b484ee402 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedPaymentTransactionDataScopeTests.cs @@ -0,0 +1,68 @@ +#nullable enable + +using Grand.Domain.Customers; +using Grand.Domain.Payments; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class RoutedPaymentTransactionDataScopeTests +{ + private const string StaffStoreId = "store-1"; + + private GlobalAdminDataScope _adminScope = null!; + private StorePaymentTransactionDataScope _storeScope = null!; + + [TestInitialize] + public void Setup() + { + var workContext = new Mock(); + workContext.Setup(x => x.CurrentCustomer).Returns(new Customer { StaffStoreId = StaffStoreId }); + var contextAccessor = new Mock(); + contextAccessor.Setup(x => x.WorkContext).Returns(workContext.Object); + + _adminScope = new GlobalAdminDataScope(); + _storeScope = new StorePaymentTransactionDataScope(contextAccessor.Object); + } + + private RoutedPaymentTransactionDataScope ResolverForArea(string? area) + { + var httpContext = new DefaultHttpContext(); + if (area is not null) httpContext.Request.RouteValues["area"] = area; + var httpContextAccessor = new Mock(); + httpContextAccessor.Setup(x => x.HttpContext).Returns(httpContext); + return new RoutedPaymentTransactionDataScope(httpContextAccessor.Object, _adminScope, _storeScope); + } + + [TestMethod] + public void AdminArea_ResolvesToAdminScope() + { + var resolver = ResolverForArea("Admin"); + Assert.IsNull(resolver.DefaultStoreId); + } + + [TestMethod] + public void StoreArea_ResolvesToStoreScope() + { + var resolver = ResolverForArea("Store"); + Assert.AreEqual(StaffStoreId, resolver.DefaultStoreId); + } + + [TestMethod] + public void VendorOrUnrecognizedOrMissingArea_ThrowsFailClosed() + { + var vendorResolver = ResolverForArea("Vendor"); + Assert.ThrowsExactly(() => _ = vendorResolver.ResourceKeyPrefix); + + var unknownResolver = ResolverForArea("Vue"); + Assert.ThrowsExactly(() => _ = unknownResolver.ResourceKeyPrefix); + + var noAreaResolver = ResolverForArea(null); + Assert.ThrowsExactly(() => _ = noAreaResolver.ResourceKeyPrefix); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/StorePaymentTransactionDataScopeTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/StorePaymentTransactionDataScopeTests.cs new file mode 100644 index 000000000..662998b61 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/StorePaymentTransactionDataScopeTests.cs @@ -0,0 +1,51 @@ +using Grand.Domain.Customers; +using Grand.Domain.Payments; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class StorePaymentTransactionDataScopeTests +{ + private static StorePaymentTransactionDataScope Build(string staffStoreId) + { + var customer = new Customer { StaffStoreId = staffStoreId }; + var workContextMock = new Mock(); + workContextMock.Setup(w => w.CurrentCustomer).Returns(customer); + var contextAccessorMock = new Mock(); + contextAccessorMock.Setup(c => c.WorkContext).Returns(workContextMock.Object); + return new StorePaymentTransactionDataScope(contextAccessorMock.Object); + } + + [TestMethod] + public async Task HasAccess_MatchingStoreId_True() + { + var scope = Build("store-1"); + Assert.IsTrue(await scope.HasAccess(new PaymentTransaction { StoreId = "store-1" })); + } + + [TestMethod] + public async Task HasAccess_MismatchedStoreId_False() + { + var scope = Build("store-1"); + Assert.IsFalse(await scope.HasAccess(new PaymentTransaction { StoreId = "store-2" })); + } + + [TestMethod] + public async Task HasAccess_NullEntity_False() + { + var scope = Build("store-1"); + Assert.IsFalse(await scope.HasAccess(null)); + } + + [TestMethod] + public void DefaultStoreId_ReturnsStaffStoreId() + { + var scope = Build("store-1"); + Assert.AreEqual("store-1", scope.DefaultStoreId); + Assert.IsNull(scope.DefaultVendorId); + } +} diff --git a/src/Web/Grand.Web.AdminShared/Services/RoutedPaymentTransactionDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/RoutedPaymentTransactionDataScope.cs new file mode 100644 index 000000000..376670d5b --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/RoutedPaymentTransactionDataScope.cs @@ -0,0 +1,52 @@ +#nullable enable + +using Grand.Domain.Payments; +using Grand.Web.AdminShared.Interfaces; +using Microsoft.AspNetCore.Http; + +namespace Grand.Web.AdminShared.Services; + +/// +/// Resolves the correct per-host +/// implementation at request time, based on the current request's "area" route value — same +/// fix and same reason as /: +/// Grand.Web (the combined host) ProjectReferences Grand.Web.Admin and +/// Grand.Web.Store directly and discovers every IStartupApplication via assembly +/// scan, so both hosts' StartupApplication.ConfigureServices calls run in the one +/// process — a plain per-host AddScoped<IAdminDataScope<PaymentTransaction>, X>() +/// would let whichever host's registration ran last silently win for both areas. +/// +/// No Vendor branch: Grand.Web.Vendor has no PaymentTransactionController at all, +/// so any "Vendor" (or other unrecognized/missing) area value fails closed, same shape as +/// . +/// +public class RoutedPaymentTransactionDataScope( + IHttpContextAccessor httpContextAccessor, + GlobalAdminDataScope adminScope, + StorePaymentTransactionDataScope storeScope) : IAdminDataScope +{ + private IAdminDataScope Resolved + { + get + { + var area = httpContextAccessor.HttpContext?.Request.RouteValues["area"] as string; + return area switch { + "Admin" => adminScope, + "Store" => storeScope, + //fail closed: this object fronts store tenant isolation, so an unrecognized or + //missing area (including "Vendor" - PaymentTransaction has no Vendor screen) must + //never silently resolve to the unscoped global scope + _ => throw new InvalidOperationException( + $"RoutedPaymentTransactionDataScope: unrecognized or missing area '{area}'.") + }; + } + } + + public Task HasAccess(PaymentTransaction entity) => Resolved.HasAccess(entity); + public Task CanView(PaymentTransaction entity) => Resolved.CanView(entity); + public string? DefaultStoreId => Resolved.DefaultStoreId; + public string ResourceKeyPrefix => Resolved.ResourceKeyPrefix; + public bool ShowStoreSelector => Resolved.ShowStoreSelector; + public string? DefaultVendorId => Resolved.DefaultVendorId; + public bool CanFeatureOnHomepage => Resolved.CanFeatureOnHomepage; +} diff --git a/src/Web/Grand.Web.AdminShared/Services/StorePaymentTransactionDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/StorePaymentTransactionDataScope.cs new file mode 100644 index 000000000..6912a411e --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/StorePaymentTransactionDataScope.cs @@ -0,0 +1,32 @@ +#nullable enable + +using Grand.Domain.Payments; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Interfaces; + +namespace Grand.Web.AdminShared.Services; + +/// +/// Store's . Bespoke, not the generic +/// : PaymentTransaction is a plain +/// with a single StoreId field, not +/// IStoreLinkEntity (no Stores/LimitedToStores list), so the generic +/// class's where TEntity : BaseEntity, IStoreLinkEntity constraint doesn't apply. +/// Mirrors Store's original controller's paymentTransaction.StoreId != StaffStoreId +/// check, repeated at every action site in that file. No override: +/// Store's original code has one uniform check for both viewing and mutating, unlike +/// Category/Product's loose/strict split. +/// +public class StorePaymentTransactionDataScope(IContextAccessor contextAccessor) + : IAdminDataScope +{ + public Task HasAccess(PaymentTransaction entity) => + Task.FromResult(entity is not null && + entity.StoreId == contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); + + public string? DefaultStoreId => contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + public string ResourceKeyPrefix => "Admin"; + public bool ShowStoreSelector => true; + public string? DefaultVendorId => null; + public bool CanFeatureOnHomepage => true; // unused for PaymentTransaction; required interface member +} diff --git a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs index cd4355fcc..6d43b4875 100644 --- a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs @@ -2,6 +2,7 @@ using elFinder.Net.Drivers.FileSystem.Extensions; using Grand.Domain.Catalog; using Grand.Domain.Orders; +using Grand.Domain.Payments; using Grand.Infrastructure; using Grand.Web.AdminShared.Interfaces; using Grand.Web.AdminShared.Services; @@ -90,6 +91,13 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); services.AddScoped(); services.AddScoped, RoutedOrderDataScope>(); + + // IAdminDataScope: registered once here for the same reason as + // Product/Category/Collection/Order above — see RoutedPaymentTransactionDataScope's doc + // comment. No Vendor scope: PaymentTransaction has no Vendor screen. + services.AddScoped>(); + services.AddScoped(); + services.AddScoped, RoutedPaymentTransactionDataScope>(); } public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) From 8f549be572c52bbc596b1677cb6b5346022964a2 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Wed, 26 Aug 2026 08:40:30 +0200 Subject: [PATCH 2/8] feat(arch001): add BasePaymentTransactionController skeleton with List region Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013XGyrBT97Wr9zjxgDmG2KR --- .../BasePaymentTransactionControllerTests.cs | 137 ++++++++++++++ .../BasePaymentTransactionController.cs | 167 ++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/BasePaymentTransactionControllerTests.cs create mode 100644 src/Web/Grand.Web.AdminShared/Controllers/BasePaymentTransactionController.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BasePaymentTransactionControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BasePaymentTransactionControllerTests.cs new file mode 100644 index 000000000..a3130cc2e --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BasePaymentTransactionControllerTests.cs @@ -0,0 +1,137 @@ +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Checkout.Payments; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain; +using Grand.Domain.Orders; +using Grand.Domain.Payments; +using Grand.Mediator; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BasePaymentTransactionControllerTests +{ + private class TestPaymentTransactionController( + IPaymentTransactionService paymentTransactionService, + IOrderService orderService, + ITranslationService translationService, + IDateTimeService dateTimeService, + IMediator mediator, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) + : BasePaymentTransactionController(paymentTransactionService, orderService, translationService, + dateTimeService, mediator, enumTranslationService, scope); + + private TestPaymentTransactionController _controller; + private Mock _paymentTransactionServiceMock; + private Mock _orderServiceMock; + private Mock> _scopeMock; + // Declared as a field (not a local in Setup()) because later tasks in this plan (Task 3, Task + // 4) add further .Setup() calls on it from their own test methods for Capture/Refund/Void/ + // partial-refund-and-paid commands. + private Mock _mediatorMock; + + [TestInitialize] + public void Setup() + { + _paymentTransactionServiceMock = new Mock(); + _orderServiceMock = new Mock(); + _scopeMock = new Mock>(); + _scopeMock.Setup(s => s.HasAccess(It.IsAny())).ReturnsAsync(true); + + var translationServiceMock = new Mock(); + translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); + var dateTimeServiceMock = new Mock(); + dateTimeServiceMock.Setup(d => d.ConvertToUserTime(It.IsAny(), It.IsAny())) + .Returns((DateTime dt, DateTimeKind _) => dt); + _mediatorMock = new Mock(); + var enumTranslationServiceMock = new Mock(); + // ToSelectList is generic with a `where TEnum : struct` constraint, so the setup must + // target the concrete enum actually passed at the List() call site (TransactionStatus), not + // the abstract System.Enum base class - Enum doesn't satisfy `struct` and won't compile as a + // type argument here. The three-argument overload (with the optional valuesToExclude) is the + // one the compiler binds to, so the setup must match all three. + enumTranslationServiceMock + .Setup(e => e.ToSelectList(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new SelectList(new List())); + + _controller = new TestPaymentTransactionController( + _paymentTransactionServiceMock.Object, _orderServiceMock.Object, translationServiceMock.Object, + dateTimeServiceMock.Object, _mediatorMock.Object, enumTranslationServiceMock.Object, _scopeMock.Object); + } + + [TestMethod] + public void List_ReturnsViewWithPreparedModel() + { + var result = _controller.List() as ViewResult; + + Assert.IsNotNull(result); + Assert.IsInstanceOfType(result.Model, typeof(PaymentTransactionListModel)); + } + + [TestMethod] + public async Task List_Post_AdminScope_DoesNotForceStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _paymentTransactionServiceMock + .Setup(s => s.SearchPaymentTransactions(null, null, null, null, 0, int.MaxValue, null, null)) + .ReturnsAsync(new PagedList(new List(), 0, 10)); + + var model = new PaymentTransactionListModel(); + await _controller.List(new DataSourceRequest { Page = 1, PageSize = int.MaxValue }, model); + + Assert.IsNull(model.StoreId); + } + + [TestMethod] + public async Task List_Post_StoreScope_ForcesStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _paymentTransactionServiceMock + .Setup(s => s.SearchPaymentTransactions(null, "store-1", It.IsAny(), null, 0, int.MaxValue, null, null)) + .ReturnsAsync(new PagedList(new List(), 0, 10)); + + var model = new PaymentTransactionListModel { StoreId = "some-other-store" }; + await _controller.List(new DataSourceRequest { Page = 1, PageSize = int.MaxValue }, model); + + Assert.AreEqual("store-1", model.StoreId); + } + + [TestMethod] + public async Task GoToOrderNumber_DeniedByScope_RedirectsToList() + { + _orderServiceMock.Setup(s => s.GetOrderByNumber(It.IsAny())).ReturnsAsync(new Order { OrderGuid = Guid.NewGuid() }); + _paymentTransactionServiceMock.Setup(s => s.GetOrderByGuid(It.IsAny())) + .ReturnsAsync(new PaymentTransaction { Id = "pt-1" }); + _scopeMock.Setup(s => s.HasAccess(It.IsAny())).ReturnsAsync(false); + + var result = await _controller.GoToOrderNumber(new PaymentTransactionListModel { OrderNumber = "123" }) as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("List", result.ActionName); + } + + [TestMethod] + public async Task GoToOrderNumber_Authorized_RedirectsToEdit() + { + _orderServiceMock.Setup(s => s.GetOrderByNumber(It.IsAny())).ReturnsAsync(new Order { OrderGuid = Guid.NewGuid() }); + _paymentTransactionServiceMock.Setup(s => s.GetOrderByGuid(It.IsAny())) + .ReturnsAsync(new PaymentTransaction { Id = "pt-1" }); + + var result = await _controller.GoToOrderNumber(new PaymentTransactionListModel { OrderNumber = "123" }) as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("Edit", result.ActionName); + Assert.AreEqual("pt-1", result.RouteValues["id"]); + } +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BasePaymentTransactionController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BasePaymentTransactionController.cs new file mode 100644 index 000000000..e172e3d04 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BasePaymentTransactionController.cs @@ -0,0 +1,167 @@ +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Checkout.Payments; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Payments; +using Grand.Domain.Permissions; +using Grand.Mediator; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.PaymentTransactions)] +public abstract class BasePaymentTransactionController( + IPaymentTransactionService paymentTransactionService, + IOrderService orderService, + ITranslationService translationService, + IDateTimeService dateTimeService, + IMediator mediator, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) + : BaseController +{ + // Exposed for host-specific concrete subclasses (none currently need extra actions, unlike + // Shipment's Admin-only EditUserFields, but the accessors are added for consistency with + // every other Base*Controller and so Task 3/4's own file sections can reference them). + protected IPaymentTransactionService PaymentTransactionService => paymentTransactionService; + protected IOrderService OrderService => orderService; + protected ITranslationService TranslationService => translationService; + protected IDateTimeService DateTimeService => dateTimeService; + protected IMediator Mediator => mediator; + protected IEnumTranslationService EnumTranslationService => enumTranslationService; + protected IAdminDataScope Scope => scope; + + /// DRY replacement for the repeated "load payment transaction, redirect to List if not + /// found or not authorized" pattern found in both original controllers (Admin never denied; + /// Store denied on StoreId mismatch at every single action). Not a behavior change — every call + /// site below still individually returns RedirectToAction("List") exactly as the originals + /// did. + protected async Task<(PaymentTransaction paymentTransaction, IActionResult denied)> LoadAuthorizedPaymentTransaction(string id) + { + var paymentTransaction = await paymentTransactionService.GetById(id); + if (paymentTransaction == null) return (null, RedirectToAction("List", "PaymentTransaction")); + if (!await scope.HasAccess(paymentTransaction)) return (null, RedirectToAction("List", "PaymentTransaction")); + return (paymentTransaction, null); + } + + #region List + + public IActionResult Index() => RedirectToAction("List"); + + public IActionResult List() + { + var model = new PaymentTransactionListModel { + PaymentTransactionStatus = enumTranslationService.ToSelectList(TransactionStatus.Pending, false) + .ToList() + }; + model.PaymentTransactionStatus.Insert(0, + new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = "-1", Selected = true }); + return View(model); + } + + /// Store's original forced model.StoreId = StaffStoreId unconditionally at the + /// top of this action, overwriting whatever the posted grid model carried — not the usual + /// "default when null" forcing pattern, because the List grid never lets a Store user pick a + /// different store to begin with (there's no store selector on this screen for Store; compare + /// Order's list, which does have one). Admin's original had no such line at all. Reproduced here + /// as an unconditional assignment gated on scope.DefaultStoreId being non-null, which is + /// exactly true for Store and exactly false (null) for Admin — same observable behavior as + /// both originals, expressed once. + [PermissionAuthorizeAction(PermissionActionName.List)] + [HttpPost] + public async Task List(DataSourceRequest command, PaymentTransactionListModel model) + { + if (scope.DefaultStoreId is not null) model.StoreId = scope.DefaultStoreId; + + DateTime? startDateValue = model.StartDate == null + ? null + : dateTimeService.ConvertToUtcTime(model.StartDate.Value, dateTimeService.CurrentTimeZone); + + DateTime? endDateValue = model.EndDate == null + ? null + : dateTimeService.ConvertToUtcTime(model.EndDate.Value, dateTimeService.CurrentTimeZone); + + Guid? orderGuid = null; + if (!string.IsNullOrEmpty(model.OrderNumber)) + if (int.TryParse(model.OrderNumber, out var ordernumber)) + { + var order = await orderService.GetOrderByNumber(ordernumber); + if (order != null) + orderGuid = order.OrderGuid; + } + + var paymentTransactions = await paymentTransactionService.SearchPaymentTransactions( + customerEmail: model.SearchCustomerEmail, + ts: model.SearchTransactionStatus >= 0 ? (TransactionStatus)model.SearchTransactionStatus : null, + createdFromUtc: startDateValue, + createdToUtc: endDateValue, + storeId: model.StoreId, + orderGuid: orderGuid, + pageIndex: command.Page - 1, + pageSize: command.PageSize); + + var dataModel = new List(); + + foreach (var item in paymentTransactions) + { + var order = await orderService.GetOrderByGuid(item.OrderGuid); + var trmodel = new PaymentTransactionModel { + Id = item.Id, + OrderCode = item.OrderCode, + CustomerEmail = item.CustomerEmail, + CustomerId = item.CustomerId, + CurrencyCode = item.CurrencyCode, + TransactionAmount = item.TransactionAmount, + PaidAmount = item.PaidAmount, + PaymentMethodSystemName = item.PaymentMethodSystemName, + RefundedAmount = item.RefundedAmount, + OrderId = order?.Id, + OrderNumber = order?.OrderNumber, + CreatedOn = dateTimeService.ConvertToUserTime(item.CreatedOnUtc, DateTimeKind.Utc), + TransactionStatus = item.TransactionStatus, + Status = enumTranslationService.GetTranslationEnum(item.TransactionStatus) + }; + dataModel.Add(trmodel); + } + + var gridModel = new DataSourceResult { + Data = dataModel.ToList(), + Total = paymentTransactions.TotalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task GoToOrderNumber(PaymentTransactionListModel model) + { + if (model.OrderNumber == null) + return RedirectToAction("List", "PaymentTransaction"); + + int.TryParse(model.OrderNumber, out var id); + + var order = await orderService.GetOrderByNumber(id); + if (order == null) + return RedirectToAction("List", "PaymentTransaction"); + + var paymentTransaction = await paymentTransactionService.GetOrderByGuid(order.OrderGuid); + if (paymentTransaction == null) + //not found + return RedirectToAction("List", "PaymentTransaction"); + + if (!await scope.HasAccess(paymentTransaction)) + return RedirectToAction("List", "PaymentTransaction"); + + return RedirectToAction("Edit", "PaymentTransaction", new { id = paymentTransaction.Id }); + } + + #endregion +} From 512620ba2214b1d9ac530eacd7e62967ac76ddb4 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Wed, 26 Aug 2026 08:45:04 +0200 Subject: [PATCH 3/8] feat(arch001): add Edit + payment action buttons region to BasePaymentTransactionController Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013XGyrBT97Wr9zjxgDmG2KR --- .../BasePaymentTransactionControllerTests.cs | 55 ++++++ .../BasePaymentTransactionController.cs | 175 ++++++++++++++++++ 2 files changed, 230 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BasePaymentTransactionControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BasePaymentTransactionControllerTests.cs index a3130cc2e..6b072024f 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BasePaymentTransactionControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BasePaymentTransactionControllerTests.cs @@ -1,3 +1,4 @@ +using Grand.Business.Core.Commands.Checkout.Orders; using Grand.Business.Core.Interfaces.Checkout.Orders; using Grand.Business.Core.Interfaces.Checkout.Payments; using Grand.Business.Core.Interfaces.Common.Directory; @@ -134,4 +135,58 @@ public async Task GoToOrderNumber_Authorized_RedirectsToEdit() Assert.AreEqual("Edit", result.ActionName); Assert.AreEqual("pt-1", result.RouteValues["id"]); } + + [TestMethod] + public async Task Edit_DeniedByScope_RedirectsToList() + { + _paymentTransactionServiceMock.Setup(s => s.GetById("pt-1")).ReturnsAsync(new PaymentTransaction { Id = "pt-1" }); + _scopeMock.Setup(s => s.HasAccess(It.IsAny())).ReturnsAsync(false); + + var result = await _controller.Edit("pt-1") as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("List", result.ActionName); + } + + [TestMethod] + public async Task Edit_Authorized_ReturnsViewWithModel() + { + var transaction = new PaymentTransaction { Id = "pt-1", OrderGuid = Guid.NewGuid(), TransactionAmount = 100, RefundedAmount = 20, PaidAmount = 100 }; + _paymentTransactionServiceMock.Setup(s => s.GetById("pt-1")).ReturnsAsync(transaction); + _orderServiceMock.Setup(s => s.GetOrderByGuid(transaction.OrderGuid)).ReturnsAsync(new Order { Id = "order-1", OrderNumber = 42 }); + + var result = await _controller.Edit("pt-1") as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as PaymentTransactionModel; + Assert.IsNotNull(model); + Assert.AreEqual("pt-1", model.Id); + Assert.AreEqual(80, model.MaxAmountToRefund); + } + + [TestMethod] + public async Task CapturePaymentTransaction_DeniedByScope_RedirectsToList() + { + _paymentTransactionServiceMock.Setup(s => s.GetById("pt-1")).ReturnsAsync(new PaymentTransaction { Id = "pt-1" }); + _scopeMock.Setup(s => s.HasAccess(It.IsAny())).ReturnsAsync(false); + + var result = await _controller.CapturePaymentTransaction("pt-1") as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("List", result.ActionName); + } + + [TestMethod] + public async Task CapturePaymentTransaction_Authorized_SendsCaptureCommandAndRedirectsToEdit() + { + var transaction = new PaymentTransaction { Id = "pt-1" }; + _paymentTransactionServiceMock.Setup(s => s.GetById("pt-1")).ReturnsAsync(transaction); + _mediatorMock.Setup(m => m.Send(It.IsAny(), default)).ReturnsAsync(new List()); + + var result = await _controller.CapturePaymentTransaction("pt-1") as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("Edit", result.ActionName); + Assert.AreEqual("pt-1", result.RouteValues["id"]); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BasePaymentTransactionController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BasePaymentTransactionController.cs index e172e3d04..992edcad7 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BasePaymentTransactionController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BasePaymentTransactionController.cs @@ -1,7 +1,9 @@ +using Grand.Business.Core.Commands.Checkout.Orders; using Grand.Business.Core.Interfaces.Checkout.Orders; using Grand.Business.Core.Interfaces.Checkout.Payments; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Queries.Checkout.Orders; using Grand.Domain.Payments; using Grand.Domain.Permissions; using Grand.Mediator; @@ -164,4 +166,177 @@ public async Task GoToOrderNumber(PaymentTransactionListModel mod } #endregion + + #region Edit + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public async Task Edit(string id) + { + var (paymentTransaction, denied) = await LoadAuthorizedPaymentTransaction(id); + if (denied != null) return denied; + + var order = await orderService.GetOrderByGuid(paymentTransaction.OrderGuid); + + var model = new PaymentTransactionModel { + Id = paymentTransaction.Id, + OrderCode = paymentTransaction.OrderCode, + CustomerEmail = string.IsNullOrEmpty(paymentTransaction.CustomerEmail) + ? "(null)" + : paymentTransaction.CustomerEmail, + CustomerId = paymentTransaction.CustomerId, + CurrencyCode = paymentTransaction.CurrencyCode, + TransactionAmount = paymentTransaction.TransactionAmount, + PaidAmount = paymentTransaction.PaidAmount, + PaymentMethodSystemName = paymentTransaction.PaymentMethodSystemName, + RefundedAmount = paymentTransaction.RefundedAmount, + OrderId = order?.Id, + OrderNumber = order?.OrderNumber, + CreatedOn = dateTimeService.ConvertToUserTime(paymentTransaction.CreatedOnUtc, DateTimeKind.Utc), + TransactionStatus = paymentTransaction.TransactionStatus, + Status = enumTranslationService.GetTranslationEnum(paymentTransaction.TransactionStatus), + IPAddress = paymentTransaction.IPAddress, + Description = paymentTransaction.Description, + AdditionalInfo = paymentTransaction.AdditionalInfo, + AuthorizationTransactionId = paymentTransaction.AuthorizationTransactionId, + CanCapture = await mediator.Send(new CanCaptureQuery { PaymentTransaction = paymentTransaction }), + CanMarkAsPaid = await mediator.Send(new CanMarkPaymentTransactionAsPaidQuery { PaymentTransaction = paymentTransaction }), + CanRefund = await mediator.Send(new CanRefundQuery { PaymentTransaction = paymentTransaction }), + CanRefundOffline = await mediator.Send(new CanRefundOfflineQuery { PaymentTransaction = paymentTransaction }), + CanPartiallyRefund = await mediator.Send(new CanPartiallyRefundQuery { PaymentTransaction = paymentTransaction, AmountToRefund = 0 }), + CanPartiallyRefundOffline = await mediator.Send(new CanPartiallyRefundOfflineQuery { PaymentTransaction = paymentTransaction, AmountToRefund = 0 }), + CanPartiallyPaidOffline = await mediator.Send(new CanPartiallyPaidOfflineQuery { PaymentTransaction = paymentTransaction, AmountToPaid = 0 }), + CanVoid = await mediator.Send(new CanVoidQuery { PaymentTransaction = paymentTransaction }), + CanVoidOffline = await mediator.Send(new CanVoidOfflineQuery { PaymentTransaction = paymentTransaction }), + MaxAmountToRefund = paymentTransaction.TransactionAmount - paymentTransaction.RefundedAmount, + MaxAmountToPaid = paymentTransaction.TransactionAmount - paymentTransaction.PaidAmount + }; + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task CapturePaymentTransaction(string id) + { + var (paymentTransaction, denied) = await LoadAuthorizedPaymentTransaction(id); + if (denied != null) return denied; + + try + { + var errors = await mediator.Send(new CaptureCommand { PaymentTransaction = paymentTransaction }); + + foreach (var error in errors) + Error(error); + + return RedirectToAction("Edit", "PaymentTransaction", new { id }); + } + catch (Exception exc) + { + Error(exc, false); + return RedirectToAction("Edit", "PaymentTransaction", new { id }); + } + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task MarkPaymentTransactionAsPaid(string id) + { + var (paymentTransaction, denied) = await LoadAuthorizedPaymentTransaction(id); + if (denied != null) return denied; + + try + { + await mediator.Send(new MarkAsPaidCommand { PaymentTransaction = paymentTransaction }); + return RedirectToAction("Edit", "PaymentTransaction", new { id }); + } + catch (Exception exc) + { + Error(exc, false); + return RedirectToAction("Edit", "PaymentTransaction", new { id }); + } + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RefundPaymentTransaction(string id) + { + var (paymentTransaction, denied) = await LoadAuthorizedPaymentTransaction(id); + if (denied != null) return denied; + + try + { + var errors = await mediator.Send(new RefundCommand { PaymentTransaction = paymentTransaction }); + foreach (var error in errors) + Error(error); + + return RedirectToAction("Edit", "PaymentTransaction", new { id }); + } + catch (Exception exc) + { + Error(exc, false); + return RedirectToAction("Edit", "PaymentTransaction", new { id }); + } + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RefundPaymentTransactionOffline(string id) + { + var (paymentTransaction, denied) = await LoadAuthorizedPaymentTransaction(id); + if (denied != null) return denied; + + try + { + await mediator.Send(new RefundOfflineCommand { PaymentTransaction = paymentTransaction }); + return RedirectToAction("Edit", "PaymentTransaction", new { id }); + } + catch (Exception exc) + { + Error(exc, false); + return RedirectToAction("Edit", "PaymentTransaction", new { id }); + } + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task VoidPaymentTransaction(string id) + { + var (paymentTransaction, denied) = await LoadAuthorizedPaymentTransaction(id); + if (denied != null) return denied; + + try + { + var errors = await mediator.Send(new VoidCommand { PaymentTransaction = paymentTransaction }); + foreach (var error in errors) + Error(error); + + return RedirectToAction("Edit", "PaymentTransaction", new { id }); + } + catch (Exception exc) + { + Error(exc, false); + return RedirectToAction("Edit", "PaymentTransaction", new { id }); + } + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task VoidPaymentTransactionOffline(string id) + { + var (paymentTransaction, denied) = await LoadAuthorizedPaymentTransaction(id); + if (denied != null) return denied; + + try + { + await mediator.Send(new VoidOfflineCommand { PaymentTransaction = paymentTransaction }); + return RedirectToAction("Edit", "PaymentTransaction", new { id }); + } + catch (Exception exc) + { + Error(exc, false); + return RedirectToAction("Edit", "PaymentTransaction", new { id }); + } + } + + #endregion } From 81e79ceec0c34e8927fb9162da3a1a036ca3efae Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Wed, 26 Aug 2026 08:50:33 +0200 Subject: [PATCH 4/8] feat(arch001): add partial refund/paid popups and Delete region, fix RefreshPage bug Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013XGyrBT97Wr9zjxgDmG2KR --- .../BasePaymentTransactionControllerTests.cs | 88 ++++++++++++ .../BasePaymentTransactionController.cs | 133 ++++++++++++++++++ 2 files changed, 221 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BasePaymentTransactionControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BasePaymentTransactionControllerTests.cs index 6b072024f..5364764e3 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BasePaymentTransactionControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BasePaymentTransactionControllerTests.cs @@ -12,8 +12,12 @@ using Grand.Web.AdminShared.Models.Orders; using Grand.Web.Common.DataSource; using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -69,6 +73,21 @@ public void Setup() _controller = new TestPaymentTransactionController( _paymentTransactionServiceMock.Object, _orderServiceMock.Object, translationServiceMock.Object, dateTimeServiceMock.Object, _mediatorMock.Object, enumTranslationServiceMock.Object, _scopeMock.Object); + + // Error()/Success() (used by this task's popup and Delete actions) reach into + // HttpContext.RequestServices for ILoggerFactory and into TempData - wire up the same + // minimal harness other Base*ControllerTests use (see BaseOrderControllerTests.Setup). + var httpContext = new DefaultHttpContext(); + var loggerFactoryMock = new Mock(); + loggerFactoryMock.Setup(l => l.CreateLogger(It.IsAny())).Returns(new Mock().Object); + var urlHelperFactoryMock = new Mock(); + urlHelperFactoryMock.Setup(f => f.GetUrlHelper(It.IsAny())).Returns(new Mock().Object); + var requestServicesMock = new Mock(); + requestServicesMock.Setup(s => s.GetService(typeof(ILoggerFactory))).Returns(loggerFactoryMock.Object); + requestServicesMock.Setup(s => s.GetService(typeof(IUrlHelperFactory))).Returns(urlHelperFactoryMock.Object); + httpContext.RequestServices = requestServicesMock.Object; + _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + _controller.TempData = new TempDataDictionary(httpContext, new Mock().Object); } [TestMethod] @@ -189,4 +208,73 @@ public async Task CapturePaymentTransaction_Authorized_SendsCaptureCommandAndRed Assert.AreEqual("Edit", result.ActionName); Assert.AreEqual("pt-1", result.RouteValues["id"]); } + + [TestMethod] + public async Task PartiallyRefundPopup_Post_Success_SetsModelRefreshPage() + { + var transaction = new PaymentTransaction { Id = "pt-1", TransactionAmount = 100, RefundedAmount = 0 }; + _paymentTransactionServiceMock.Setup(s => s.GetById("pt-1")).ReturnsAsync(transaction); + _mediatorMock.Setup(m => m.Send(It.IsAny(), default)).ReturnsAsync(true); + + var model = new PaymentTransactionModel { AmountToRefund = 50 }; + var result = await _controller.PartiallyRefundPopup("pt-1", false, model) as ViewResult; + + Assert.IsNotNull(result); + var resultModel = result.Model as PaymentTransactionModel; + Assert.IsTrue(resultModel.RefreshPage); + } + + [TestMethod] + public async Task PartiallyRefundPopup_Post_ZeroAmount_ErrorsAndDoesNotSetRefreshPage() + { + var transaction = new PaymentTransaction { Id = "pt-1", TransactionAmount = 100, RefundedAmount = 0 }; + _paymentTransactionServiceMock.Setup(s => s.GetById("pt-1")).ReturnsAsync(transaction); + + var model = new PaymentTransactionModel { AmountToRefund = 0 }; + var result = await _controller.PartiallyRefundPopup("pt-1", false, model) as ViewResult; + + Assert.IsNotNull(result); + var resultModel = result.Model as PaymentTransactionModel; + Assert.IsFalse(resultModel.RefreshPage); + } + + [TestMethod] + public async Task PartiallyPaidPopup_Post_Success_SetsModelRefreshPage() + { + var transaction = new PaymentTransaction { Id = "pt-1", TransactionAmount = 100, PaidAmount = 0 }; + _paymentTransactionServiceMock.Setup(s => s.GetById("pt-1")).ReturnsAsync(transaction); + _mediatorMock.Setup(m => m.Send(It.IsAny(), default)).ReturnsAsync(true); + + var model = new PaymentTransactionModel { AmountToPaid = 50 }; + var result = await _controller.PartiallyPaidPopup("pt-1", false, model) as ViewResult; + + Assert.IsNotNull(result); + var resultModel = result.Model as PaymentTransactionModel; + Assert.IsTrue(resultModel.RefreshPage); + } + + [TestMethod] + public async Task Delete_DeniedByScope_RedirectsToList() + { + _paymentTransactionServiceMock.Setup(s => s.GetById("pt-1")).ReturnsAsync(new PaymentTransaction { Id = "pt-1" }); + _scopeMock.Setup(s => s.HasAccess(It.IsAny())).ReturnsAsync(false); + + var result = await _controller.Delete("pt-1") as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("List", result.ActionName); + } + + [TestMethod] + public async Task Delete_Authorized_DeletesAndRedirectsToList() + { + var transaction = new PaymentTransaction { Id = "pt-1" }; + _paymentTransactionServiceMock.Setup(s => s.GetById("pt-1")).ReturnsAsync(transaction); + + var result = await _controller.Delete("pt-1") as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("List", result.ActionName); + _paymentTransactionServiceMock.Verify(s => s.DeletePaymentTransaction(transaction), Times.Once); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BasePaymentTransactionController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BasePaymentTransactionController.cs index 992edcad7..4e833f5f0 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BasePaymentTransactionController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BasePaymentTransactionController.cs @@ -7,6 +7,7 @@ using Grand.Domain.Payments; using Grand.Domain.Permissions; using Grand.Mediator; +using Grand.SharedKernel; using Grand.Web.AdminShared.Interfaces; using Grand.Web.AdminShared.Models.Orders; using Grand.Web.Common.Controllers; @@ -339,4 +340,136 @@ public async Task VoidPaymentTransactionOffline(string id) } #endregion + + #region Partial refund / paid popups + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task PartiallyRefundPopup(string id, bool online) + { + var (paymentTransaction, denied) = await LoadAuthorizedPaymentTransaction(id); + if (denied != null) return denied; + + var model = new PaymentTransactionModel { + Id = paymentTransaction.Id, + MaxAmountToRefund = paymentTransaction.TransactionAmount - paymentTransaction.RefundedAmount, + CurrencyCode = paymentTransaction.CurrencyCode + }; + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task PartiallyRefundPopup(string id, bool online, PaymentTransactionModel model) + { + var (paymentTransaction, denied) = await LoadAuthorizedPaymentTransaction(id); + if (denied != null) return denied; + + try + { + var amountToRefund = model.AmountToRefund; + if (amountToRefund <= 0) + throw new GrandException("Enter amount to refund"); + + var maxAmountToRefund = paymentTransaction.TransactionAmount - paymentTransaction.RefundedAmount; + if (amountToRefund > maxAmountToRefund) + amountToRefund = maxAmountToRefund; + + var errors = new List(); + if (online) + errors = (await mediator.Send(new PartiallyRefundCommand { PaymentTransaction = paymentTransaction, AmountToRefund = amountToRefund })).ToList(); + else + await mediator.Send(new PartiallyRefundOfflineCommand { PaymentTransaction = paymentTransaction, AmountToRefund = amountToRefund }); + + if (errors.Count == 0) + { + //success — Model.RefreshPage, read by both hosts' PartiallyRefundPopup.cshtml script + //block after this task's view migration (Task 5). Admin's pre-consolidation + //controller set ViewBag.RefreshPage here instead, which its own view never read + //(a dead write, since the view already checked Model.RefreshPage) — deliberate bug + //fix, unifying onto Store's already-correct behavior. See spec §2. + model.RefreshPage = true; + return View(model); + } + + foreach (var error in errors) + Error(error); + + return View(model); + } + catch (Exception exc) + { + Error(exc, false); + return View(model); + } + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task PartiallyPaidPopup(string id, bool online) + { + var (paymentTransaction, denied) = await LoadAuthorizedPaymentTransaction(id); + if (denied != null) return denied; + + var model = new PaymentTransactionModel { + Id = paymentTransaction.Id, + MaxAmountToPaid = paymentTransaction.TransactionAmount - paymentTransaction.PaidAmount, + CurrencyCode = paymentTransaction.CurrencyCode + }; + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task PartiallyPaidPopup(string id, bool online, PaymentTransactionModel model) + { + var (paymentTransaction, denied) = await LoadAuthorizedPaymentTransaction(id); + if (denied != null) return denied; + + try + { + var amountToPaid = model.AmountToPaid; + if (amountToPaid <= 0) + throw new GrandException("Enter amount to refund"); + + var maxAmountToPaid = paymentTransaction.TransactionAmount - paymentTransaction.PaidAmount; + if (amountToPaid > maxAmountToPaid) + amountToPaid = maxAmountToPaid; + + await mediator.Send(new PartiallyPaidOfflineCommand { PaymentTransaction = paymentTransaction, AmountToPaid = amountToPaid }); + + //same RefreshPage fix as PartiallyRefundPopup above. + model.RefreshPage = true; + return View(model); + } + catch (Exception exc) + { + Error(exc, false); + return View(model); + } + } + + #endregion + + #region Delete + + [PermissionAuthorizeAction(PermissionActionName.Delete)] + [HttpPost] + public async Task Delete(string id) + { + var (paymentTransaction, denied) = await LoadAuthorizedPaymentTransaction(id); + if (denied != null) return denied; + + if (ModelState.IsValid) + { + await paymentTransactionService.DeletePaymentTransaction(paymentTransaction); + Success(translationService.GetResource("Admin.Orders.PaymentTransaction.Deleted")); + return RedirectToAction("List", "PaymentTransaction"); + } + + Error(ModelState); + return RedirectToAction("Edit", new { id = paymentTransaction.Id }); + } + + #endregion } From 7f2c7a3c5913a97dfabb8cbe643952bd452b88ac Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Wed, 26 Aug 2026 08:57:04 +0200 Subject: [PATCH 5/8] feat(arch001): cut Admin/Store PaymentTransactionController over to thin subclasses Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013XGyrBT97Wr9zjxgDmG2KR --- ...aymentTransactionControllerRoutingTests.cs | 19 + ...aymentTransactionControllerRoutingTests.cs | 35 ++ .../PaymentTransactionController.cs | 487 +--------------- .../PaymentTransactionController.cs | 538 +----------------- 4 files changed, 102 insertions(+), 977 deletions(-) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/PaymentTransactionControllerRoutingTests.cs create mode 100644 src/Tests/Grand.Web.Store.Tests/Controllers/PaymentTransactionControllerRoutingTests.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/PaymentTransactionControllerRoutingTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/PaymentTransactionControllerRoutingTests.cs new file mode 100644 index 000000000..eb8774f5b --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/PaymentTransactionControllerRoutingTests.cs @@ -0,0 +1,19 @@ +using Grand.Web.Admin.Controllers; +using Grand.Web.AdminShared.Controllers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class PaymentTransactionControllerRoutingTests +{ + [TestMethod] + public void AdminPaymentTransactionController_InheritsBasePaymentTransactionController() => + Assert.IsTrue(typeof(BasePaymentTransactionController).IsAssignableFrom(typeof(PaymentTransactionController))); + + [TestMethod] + public void AdminPaymentTransactionController_HasAutoValidateAntiforgeryToken() => + Assert.IsTrue(typeof(PaymentTransactionController) + .GetCustomAttributes(typeof(Microsoft.AspNetCore.Mvc.AutoValidateAntiforgeryTokenAttribute), false) + .Length > 0); +} diff --git a/src/Tests/Grand.Web.Store.Tests/Controllers/PaymentTransactionControllerRoutingTests.cs b/src/Tests/Grand.Web.Store.Tests/Controllers/PaymentTransactionControllerRoutingTests.cs new file mode 100644 index 000000000..9f5574689 --- /dev/null +++ b/src/Tests/Grand.Web.Store.Tests/Controllers/PaymentTransactionControllerRoutingTests.cs @@ -0,0 +1,35 @@ +using Grand.Web.AdminShared.Controllers; +using Grand.Web.Common.Filters; +using Grand.Web.Store.Controllers; +using Grand.Web.Store.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Store.Tests.Controllers; + +[TestClass] +public class PaymentTransactionControllerRoutingTests +{ + [TestMethod] + public void StorePaymentTransactionController_InheritsBasePaymentTransactionController() => + Assert.IsTrue(typeof(BasePaymentTransactionController).IsAssignableFrom(typeof(PaymentTransactionController))); + + [TestMethod] + public void StorePaymentTransactionController_HasAutoValidateAntiforgeryToken() => + Assert.IsTrue(typeof(PaymentTransactionController) + .GetCustomAttributes(typeof(Microsoft.AspNetCore.Mvc.AutoValidateAntiforgeryTokenAttribute), false) + .Length > 0); + + [TestMethod] + public void StorePaymentTransactionController_HasAreaAttributeWithStoreArea() + { + var areaAttr = (AreaAttribute)Attribute.GetCustomAttribute(typeof(PaymentTransactionController), typeof(AreaAttribute), false); + Assert.IsNotNull(areaAttr, "Missing [Area]."); + Assert.AreEqual(Constants.AreaStore, areaAttr.RouteValue); + } + + [TestMethod] + public void StorePaymentTransactionController_HasAuthorizeStoreAttribute() => + Assert.IsTrue(typeof(PaymentTransactionController).IsDefined(typeof(AuthorizeStoreAttribute), false), + "Missing [AuthorizeStore]."); +} diff --git a/src/Web/Grand.Web.Admin/Controllers/PaymentTransactionController.cs b/src/Web/Grand.Web.Admin/Controllers/PaymentTransactionController.cs index e41a6a57d..a00543414 100644 --- a/src/Web/Grand.Web.Admin/Controllers/PaymentTransactionController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/PaymentTransactionController.cs @@ -1,472 +1,35 @@ -using Grand.Business.Core.Commands.Checkout.Orders; using Grand.Business.Core.Interfaces.Checkout.Orders; using Grand.Business.Core.Interfaces.Checkout.Payments; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Business.Core.Queries.Checkout.Orders; using Grand.Domain.Payments; -using Grand.Domain.Permissions; -using Grand.SharedKernel; -using Grand.Web.AdminShared.Models.Orders; -using Grand.Web.Common.DataSource; -using Grand.Web.Common.Localization; -using Grand.Web.Common.Security.Authorization; using Grand.Mediator; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Rendering; namespace Grand.Web.Admin.Controllers; -[PermissionAuthorize(PermissionSystemName.PaymentTransactions)] -public class PaymentTransactionController : BaseAdminController -{ - #region Constructors - - public PaymentTransactionController( - ITranslationService translationService, - IPaymentTransactionService paymentTransactionService, - IOrderService orderService, - IDateTimeService dateTimeService, - IMediator mediator, - IEnumTranslationService enumTranslationService) - { - _translationService = translationService; - _paymentTransactionService = paymentTransactionService; - _orderService = orderService; - _dateTimeService = dateTimeService; - _mediator = mediator; - _enumTranslationService = enumTranslationService; - } - - #endregion - - #region Fields - - private readonly IPaymentTransactionService _paymentTransactionService; - private readonly IOrderService _orderService; - private readonly ITranslationService _translationService; - private readonly IDateTimeService _dateTimeService; - private readonly IMediator _mediator; - private readonly IEnumTranslationService _enumTranslationService; - - #endregion Fields - - #region Methods - - //list - public IActionResult Index() - { - return RedirectToAction("List"); - } - - public IActionResult List() - { - var model = new PaymentTransactionListModel { - PaymentTransactionStatus = _enumTranslationService.ToSelectList(TransactionStatus.Pending, false) - .ToList() - }; - model.PaymentTransactionStatus.Insert(0, - new SelectListItem { Text = _translationService.GetResource("Admin.Common.All"), Value = "-1", Selected = true }); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.List)] - [HttpPost] - public async Task List(DataSourceRequest command, PaymentTransactionListModel model) - { - DateTime? startDateValue = model.StartDate == null - ? null - : _dateTimeService.ConvertToUtcTime(model.StartDate.Value, _dateTimeService.CurrentTimeZone); - - DateTime? endDateValue = model.EndDate == null - ? null - : _dateTimeService.ConvertToUtcTime(model.EndDate.Value, _dateTimeService.CurrentTimeZone); - - Guid? orderGuid = null; - if (!string.IsNullOrEmpty(model.OrderNumber)) - if (int.TryParse(model.OrderNumber, out var ordernumber)) - { - var order = await _orderService.GetOrderByNumber(ordernumber); - if (order != null) - orderGuid = order.OrderGuid; - } - - var paymentTransactions = await _paymentTransactionService.SearchPaymentTransactions( - customerEmail: model.SearchCustomerEmail, - ts: model.SearchTransactionStatus >= 0 ? (TransactionStatus)model.SearchTransactionStatus : null, - createdFromUtc: startDateValue, - createdToUtc: endDateValue, - storeId: model.StoreId, - orderGuid: orderGuid, - pageIndex: command.Page - 1, - pageSize: command.PageSize); - - var dataModel = new List(); - - foreach (var item in paymentTransactions) - { - var order = await _orderService.GetOrderByGuid(item.OrderGuid); - var trmodel = new PaymentTransactionModel { - Id = item.Id, - OrderCode = item.OrderCode, - CustomerEmail = item.CustomerEmail, - CustomerId = item.CustomerId, - CurrencyCode = item.CurrencyCode, - TransactionAmount = item.TransactionAmount, - PaidAmount = item.PaidAmount, - PaymentMethodSystemName = item.PaymentMethodSystemName, - RefundedAmount = item.RefundedAmount, - OrderId = order?.Id, - OrderNumber = order?.OrderNumber, - CreatedOn = _dateTimeService.ConvertToUserTime(item.CreatedOnUtc, DateTimeKind.Utc), - TransactionStatus = item.TransactionStatus, - Status = _enumTranslationService.GetTranslationEnum(item.TransactionStatus) - }; - dataModel.Add(trmodel); - } - - var gridModel = new DataSourceResult { - Data = dataModel.ToList(), - Total = paymentTransactions.TotalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task GoToOrderNumber(PaymentTransactionListModel model) - { - if (model.OrderNumber == null) - return RedirectToAction("List", "PaymentTransaction"); - - int.TryParse(model.OrderNumber, out var id); - - var order = await _orderService.GetOrderByNumber(id); - if (order == null) - return RedirectToAction("List", "PaymentTransaction"); - - var paymentTransaction = await _paymentTransactionService.GetOrderByGuid(order.OrderGuid); - if (paymentTransaction == null) - //not found - return RedirectToAction("List", "PaymentTransaction"); - - return RedirectToAction("Edit", "PaymentTransaction", new { id = paymentTransaction.Id }); - } - - - //edit - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task Edit(string id) - { - var paymentTransaction = await _paymentTransactionService.GetById(id); - if (paymentTransaction == null) - return RedirectToAction("List", "PaymentTransaction"); - - var order = await _orderService.GetOrderByGuid(paymentTransaction.OrderGuid); - - var model = new PaymentTransactionModel { - Id = paymentTransaction.Id, - OrderCode = paymentTransaction.OrderCode, - CustomerEmail = string.IsNullOrEmpty(paymentTransaction.CustomerEmail) - ? "(null)" - : paymentTransaction.CustomerEmail, - CustomerId = paymentTransaction.CustomerId, - CurrencyCode = paymentTransaction.CurrencyCode, - TransactionAmount = paymentTransaction.TransactionAmount, - PaidAmount = paymentTransaction.PaidAmount, - PaymentMethodSystemName = paymentTransaction.PaymentMethodSystemName, - RefundedAmount = paymentTransaction.RefundedAmount, - OrderId = order?.Id, - OrderNumber = order?.OrderNumber, - CreatedOn = _dateTimeService.ConvertToUserTime(paymentTransaction.CreatedOnUtc, DateTimeKind.Utc), - TransactionStatus = paymentTransaction.TransactionStatus, - Status = _enumTranslationService.GetTranslationEnum(paymentTransaction.TransactionStatus), - IPAddress = paymentTransaction.IPAddress, - Description = paymentTransaction.Description, - AdditionalInfo = paymentTransaction.AdditionalInfo, - AuthorizationTransactionId = paymentTransaction.AuthorizationTransactionId, - //payment method buttons - //model.CanCancelOrder = await _mediator.Send(new CanCancelOrderQuery() { Order = order }); - CanCapture = await _mediator.Send(new CanCaptureQuery { PaymentTransaction = paymentTransaction }), - CanMarkAsPaid = await _mediator.Send(new CanMarkPaymentTransactionAsPaidQuery { PaymentTransaction = paymentTransaction }), - CanRefund = await _mediator.Send(new CanRefundQuery { PaymentTransaction = paymentTransaction }), - CanRefundOffline = await _mediator.Send(new CanRefundOfflineQuery { PaymentTransaction = paymentTransaction }), - CanPartiallyRefund = await _mediator.Send(new CanPartiallyRefundQuery { PaymentTransaction = paymentTransaction, AmountToRefund = 0 }), - CanPartiallyRefundOffline = await _mediator.Send(new CanPartiallyRefundOfflineQuery { PaymentTransaction = paymentTransaction, AmountToRefund = 0 }), - CanPartiallyPaidOffline = await _mediator.Send(new CanPartiallyPaidOfflineQuery { PaymentTransaction = paymentTransaction, AmountToPaid = 0 }), - CanVoid = await _mediator.Send(new CanVoidQuery { PaymentTransaction = paymentTransaction }), - CanVoidOffline = await _mediator.Send(new CanVoidOfflineQuery { PaymentTransaction = paymentTransaction }), - MaxAmountToRefund = paymentTransaction.TransactionAmount - paymentTransaction.RefundedAmount, - MaxAmountToPaid = paymentTransaction.TransactionAmount - paymentTransaction.PaidAmount - }; - - return View(model); - } - - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CapturePaymentTransaction(string id) - { - var paymentTransaction = await _paymentTransactionService.GetById(id); - if (paymentTransaction == null) - return RedirectToAction("List", "PaymentTransaction"); - - try - { - var errors = await _mediator.Send(new CaptureCommand { PaymentTransaction = paymentTransaction }); - - foreach (var error in errors) - Error(error); - - return RedirectToAction("Edit", "PaymentTransaction", new { id }); - } - catch (Exception exc) - { - //error - Error(exc, false); - return RedirectToAction("Edit", "PaymentTransaction", new { id }); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task MarkPaymentTransactionAsPaid(string id) - { - var paymentTransaction = await _paymentTransactionService.GetById(id); - if (paymentTransaction == null) - return RedirectToAction("List", "PaymentTransaction"); - - try - { - await _mediator.Send(new MarkAsPaidCommand { PaymentTransaction = paymentTransaction }); - return RedirectToAction("Edit", "PaymentTransaction", new { id }); - } - catch (Exception exc) - { - //error - Error(exc, false); - return RedirectToAction("Edit", "PaymentTransaction", new { id }); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RefundPaymentTransaction(string id) - { - var paymentTransaction = await _paymentTransactionService.GetById(id); - if (paymentTransaction == null) - return RedirectToAction("List", "PaymentTransaction"); - - try - { - var errors = await _mediator.Send(new RefundCommand { PaymentTransaction = paymentTransaction }); - foreach (var error in errors) - Error(error); - - return RedirectToAction("Edit", "PaymentTransaction", new { id }); - } - catch (Exception exc) - { - //error - Error(exc, false); - return RedirectToAction("Edit", "PaymentTransaction", new { id }); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RefundPaymentTransactionOffline(string id) - { - var paymentTransaction = await _paymentTransactionService.GetById(id); - if (paymentTransaction == null) - return RedirectToAction("List", "PaymentTransaction"); - - try - { - await _mediator.Send(new RefundOfflineCommand { PaymentTransaction = paymentTransaction }); - return RedirectToAction("Edit", "PaymentTransaction", new { id }); - } - catch (Exception exc) - { - //error - Error(exc, false); - return RedirectToAction("Edit", "PaymentTransaction", new { id }); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task VoidPaymentTransaction(string id) - { - var paymentTransaction = await _paymentTransactionService.GetById(id); - if (paymentTransaction == null) - return RedirectToAction("List", "PaymentTransaction"); - - try - { - var errors = await _mediator.Send(new VoidCommand { PaymentTransaction = paymentTransaction }); - foreach (var error in errors) - Error(error); - - return RedirectToAction("Edit", "PaymentTransaction", new { id }); - } - catch (Exception exc) - { - //error - Error(exc, false); - return RedirectToAction("Edit", "PaymentTransaction", new { id }); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task VoidPaymentTransactionOffline(string id) - { - var paymentTransaction = await _paymentTransactionService.GetById(id); - if (paymentTransaction == null) - return RedirectToAction("List", "PaymentTransaction"); - - try - { - await _mediator.Send(new VoidOfflineCommand { PaymentTransaction = paymentTransaction }); - return RedirectToAction("Edit", "PaymentTransaction", new { id }); - } - catch (Exception exc) - { - //error - Error(exc, false); - return RedirectToAction("Edit", "PaymentTransaction", new { id }); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task PartiallyRefundPopup(string id, bool online) - { - var paymentTransaction = await _paymentTransactionService.GetById(id); - if (paymentTransaction == null) - return RedirectToAction("List", "PaymentTransaction"); - - var model = new PaymentTransactionModel { - Id = paymentTransaction.Id, - MaxAmountToRefund = paymentTransaction.TransactionAmount - paymentTransaction.RefundedAmount, - CurrencyCode = paymentTransaction.CurrencyCode - }; - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task PartiallyRefundPopup(string id, bool online, PaymentTransactionModel model) - { - var paymentTransaction = await _paymentTransactionService.GetById(id); - if (paymentTransaction == null) - return RedirectToAction("List", "PaymentTransaction"); - - try - { - var amountToRefund = model.AmountToRefund; - if (amountToRefund <= 0) - throw new GrandException("Enter amount to refund"); - - var maxAmountToRefund = paymentTransaction.TransactionAmount - paymentTransaction.RefundedAmount; - if (amountToRefund > maxAmountToRefund) - amountToRefund = maxAmountToRefund; - - var errors = new List(); - if (online) - errors = (await _mediator.Send(new PartiallyRefundCommand { PaymentTransaction = paymentTransaction, AmountToRefund = amountToRefund })).ToList(); - else - await _mediator.Send(new PartiallyRefundOfflineCommand { PaymentTransaction = paymentTransaction, AmountToRefund = amountToRefund }); - - if (errors.Count == 0) - { - //success - ViewBag.RefreshPage = true; - return View(model); - } - - //error - foreach (var error in errors) - Error(error); - - return View(model); - } - catch (Exception exc) - { - Error(exc, false); - return View(model); - } - } - - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task PartiallyPaidPopup(string id, bool online) - { - var paymentTransaction = await _paymentTransactionService.GetById(id); - if (paymentTransaction == null) - return RedirectToAction("List", "PaymentTransaction"); - - var model = new PaymentTransactionModel { - Id = paymentTransaction.Id, - MaxAmountToPaid = paymentTransaction.TransactionAmount - paymentTransaction.PaidAmount, - CurrencyCode = paymentTransaction.CurrencyCode - }; - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task PartiallyPaidPopup(string id, bool online, PaymentTransactionModel model) - { - var paymentTransaction = await _paymentTransactionService.GetById(id); - if (paymentTransaction == null) - return RedirectToAction("List", "PaymentTransaction"); - - try - { - var amountToPaid = model.AmountToPaid; - if (amountToPaid <= 0) - throw new GrandException("Enter amount to refund"); - - var maxAmountToPaid = paymentTransaction.TransactionAmount - paymentTransaction.PaidAmount; - if (amountToPaid > maxAmountToPaid) - amountToPaid = maxAmountToPaid; - - await _mediator.Send(new PartiallyPaidOfflineCommand { PaymentTransaction = paymentTransaction, AmountToPaid = amountToPaid }); - - ViewBag.RefreshPage = true; - return View(model); - } - catch (Exception exc) - { - Error(exc, false); - return View(model); - } - } - - //delete - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task Delete(string id) - { - var paymentTransaction = await _paymentTransactionService.GetById(id); - if (paymentTransaction == null) - return RedirectToAction("List"); - - if (ModelState.IsValid) - { - await _paymentTransactionService.DeletePaymentTransaction(paymentTransaction); - Success(_translationService.GetResource("Admin.Orders.PaymentTransaction.Deleted")); - return RedirectToAction("List", "PaymentTransaction"); - } - - Error(ModelState); - return RedirectToAction("Edit", new { id = paymentTransaction.Id }); - } - - #endregion -} \ No newline at end of file +// Reduced to a thin subclass of BasePaymentTransactionController (ARCH-001 PaymentTransaction +// consolidation). All regions of behavior live in the shared base; this class only supplies +// Admin's DI wiring plus the attributes that used to arrive transitively via BaseAdminController - +// BasePaymentTransactionController can't inherit any single host's base controller (it's shared +// across Admin/Store, each with a different [Area]/[Authorize*] pair), so each subclass restates +// its own host's attribute set explicitly. Same pattern as CollectionController/CategoryController. +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class PaymentTransactionController( + IPaymentTransactionService paymentTransactionService, + IOrderService orderService, + ITranslationService translationService, + IDateTimeService dateTimeService, + IMediator mediator, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) + : BasePaymentTransactionController(paymentTransactionService, orderService, translationService, + dateTimeService, mediator, enumTranslationService, scope); diff --git a/src/Web/Grand.Web.Store/Controllers/PaymentTransactionController.cs b/src/Web/Grand.Web.Store/Controllers/PaymentTransactionController.cs index 32f071ead..5298185e2 100644 --- a/src/Web/Grand.Web.Store/Controllers/PaymentTransactionController.cs +++ b/src/Web/Grand.Web.Store/Controllers/PaymentTransactionController.cs @@ -1,525 +1,33 @@ -using Grand.Business.Core.Commands.Checkout.Orders; using Grand.Business.Core.Interfaces.Checkout.Orders; using Grand.Business.Core.Interfaces.Checkout.Payments; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Business.Core.Queries.Checkout.Orders; -using Grand.Domain.Permissions; using Grand.Domain.Payments; -using Grand.Infrastructure; -using Grand.SharedKernel; -using Grand.Web.AdminShared.Models.Orders; -using Grand.Web.Common.DataSource; -using Grand.Web.Common.Localization; -using Grand.Web.Common.Security.Authorization; using Grand.Mediator; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Store.Extensions; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Rendering; namespace Grand.Web.Store.Controllers; -[PermissionAuthorize(PermissionSystemName.PaymentTransactions)] -public class PaymentTransactionController : BaseStoreController -{ - #region Constructors - - public PaymentTransactionController( - ITranslationService translationService, - IPaymentTransactionService paymentTransactionService, - IOrderService orderService, - IContextAccessor contextAccessor, - IDateTimeService dateTimeService, - IMediator mediator, - IEnumTranslationService enumTranslationService) - { - _translationService = translationService; - _paymentTransactionService = paymentTransactionService; - _orderService = orderService; - _contextAccessor = contextAccessor; - _dateTimeService = dateTimeService; - _mediator = mediator; - _enumTranslationService = enumTranslationService; - } - - #endregion - - #region Fields - - private readonly IPaymentTransactionService _paymentTransactionService; - private readonly IOrderService _orderService; - private readonly ITranslationService _translationService; - private readonly IContextAccessor _contextAccessor; - private readonly IDateTimeService _dateTimeService; - private readonly IMediator _mediator; - private readonly IEnumTranslationService _enumTranslationService; - - #endregion Fields - - #region Methods - - //list - public IActionResult Index() - { - return RedirectToAction("List"); - } - - public IActionResult List() - { - var model = new PaymentTransactionListModel { - PaymentTransactionStatus = _enumTranslationService.ToSelectList(TransactionStatus.Pending, false) - .ToList() - }; - model.PaymentTransactionStatus.Insert(0, - new SelectListItem - { Text = _translationService.GetResource("Admin.Common.All"), Value = "-1", Selected = true }); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.List)] - [HttpPost] - public async Task List(DataSourceRequest command, PaymentTransactionListModel model) - { - model.StoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - DateTime? startDateValue = model.StartDate == null - ? null - : _dateTimeService.ConvertToUtcTime(model.StartDate.Value, _dateTimeService.CurrentTimeZone); - - DateTime? endDateValue = model.EndDate == null - ? null - : _dateTimeService.ConvertToUtcTime(model.EndDate.Value, _dateTimeService.CurrentTimeZone); - - Guid? orderGuid = null; - if (!string.IsNullOrEmpty(model.OrderNumber)) - if (int.TryParse(model.OrderNumber, out var ordernumber)) - { - var order = await _orderService.GetOrderByNumber(ordernumber); - if (order != null) - orderGuid = order.OrderGuid; - } - - var paymentTransactions = await _paymentTransactionService.SearchPaymentTransactions( - customerEmail: model.SearchCustomerEmail, - ts: model.SearchTransactionStatus >= 0 ? (TransactionStatus)model.SearchTransactionStatus : null, - createdFromUtc: startDateValue, - createdToUtc: endDateValue, - storeId: model.StoreId, - orderGuid: orderGuid, - pageIndex: command.Page - 1, - pageSize: command.PageSize); - - var dataModel = new List(); - - foreach (var item in paymentTransactions) - { - var order = await _orderService.GetOrderByGuid(item.OrderGuid); - var trmodel = new PaymentTransactionModel { - Id = item.Id, - OrderCode = item.OrderCode, - CustomerEmail = item.CustomerEmail, - CustomerId = item.CustomerId, - CurrencyCode = item.CurrencyCode, - TransactionAmount = item.TransactionAmount, - PaidAmount = item.PaidAmount, - PaymentMethodSystemName = item.PaymentMethodSystemName, - RefundedAmount = item.RefundedAmount, - OrderId = order?.Id, - OrderNumber = order?.OrderNumber, - CreatedOn = _dateTimeService.ConvertToUserTime(item.CreatedOnUtc, DateTimeKind.Utc), - TransactionStatus = item.TransactionStatus, - Status = _enumTranslationService.GetTranslationEnum(item.TransactionStatus) - }; - dataModel.Add(trmodel); - } - - var gridModel = new DataSourceResult { - Data = dataModel.ToList(), - Total = paymentTransactions.TotalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task GoToOrderNumber(PaymentTransactionListModel model) - { - if (model.OrderNumber == null) - return RedirectToAction("List", "PaymentTransaction"); - - int.TryParse(model.OrderNumber, out var id); - - var order = await _orderService.GetOrderByNumber(id); - if (order == null) - return RedirectToAction("List", "PaymentTransaction"); - - var paymentTransaction = await _paymentTransactionService.GetOrderByGuid(order.OrderGuid); - if (paymentTransaction == null) - //not found - return RedirectToAction("List", "PaymentTransaction"); - - if (paymentTransaction.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return RedirectToAction("List", "PaymentTransaction"); - - return RedirectToAction("Edit", "PaymentTransaction", new { id = paymentTransaction.Id }); - } - - - //edit - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task Edit(string id) - { - var paymentTransaction = await _paymentTransactionService.GetById(id); - if (paymentTransaction == null) - return RedirectToAction("List", "PaymentTransaction"); - - if (paymentTransaction.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return RedirectToAction("List", "PaymentTransaction"); - - var order = await _orderService.GetOrderByGuid(paymentTransaction.OrderGuid); - - var model = new PaymentTransactionModel { - Id = paymentTransaction.Id, - OrderCode = paymentTransaction.OrderCode, - CustomerEmail = string.IsNullOrEmpty(paymentTransaction.CustomerEmail) - ? "(null)" - : paymentTransaction.CustomerEmail, - CustomerId = paymentTransaction.CustomerId, - CurrencyCode = paymentTransaction.CurrencyCode, - TransactionAmount = paymentTransaction.TransactionAmount, - PaidAmount = paymentTransaction.PaidAmount, - PaymentMethodSystemName = paymentTransaction.PaymentMethodSystemName, - RefundedAmount = paymentTransaction.RefundedAmount, - OrderId = order?.Id, - OrderNumber = order?.OrderNumber, - CreatedOn = _dateTimeService.ConvertToUserTime(paymentTransaction.CreatedOnUtc, DateTimeKind.Utc), - TransactionStatus = paymentTransaction.TransactionStatus, - Status = _enumTranslationService.GetTranslationEnum(paymentTransaction.TransactionStatus), - IPAddress = paymentTransaction.IPAddress, - Description = paymentTransaction.Description, - AdditionalInfo = paymentTransaction.AdditionalInfo, - AuthorizationTransactionId = paymentTransaction.AuthorizationTransactionId, - //payment method buttons - //model.CanCancelOrder = await _mediator.Send(new CanCancelOrderQuery() { Order = order }); - CanCapture = await _mediator.Send(new CanCaptureQuery { PaymentTransaction = paymentTransaction }), - CanMarkAsPaid = await _mediator.Send(new CanMarkPaymentTransactionAsPaidQuery - { PaymentTransaction = paymentTransaction }), - CanRefund = await _mediator.Send(new CanRefundQuery { PaymentTransaction = paymentTransaction }), - CanRefundOffline = await _mediator.Send(new CanRefundOfflineQuery - { PaymentTransaction = paymentTransaction }), - CanPartiallyRefund = await _mediator.Send(new CanPartiallyRefundQuery - { PaymentTransaction = paymentTransaction, AmountToRefund = 0 }), - CanPartiallyRefundOffline = await _mediator.Send(new CanPartiallyRefundOfflineQuery - { PaymentTransaction = paymentTransaction, AmountToRefund = 0 }), - CanPartiallyPaidOffline = await _mediator.Send(new CanPartiallyPaidOfflineQuery - { PaymentTransaction = paymentTransaction, AmountToPaid = 0 }), - CanVoid = await _mediator.Send(new CanVoidQuery { PaymentTransaction = paymentTransaction }), - CanVoidOffline = await _mediator.Send(new CanVoidOfflineQuery { PaymentTransaction = paymentTransaction }), - MaxAmountToRefund = paymentTransaction.TransactionAmount - paymentTransaction.RefundedAmount, - MaxAmountToPaid = paymentTransaction.TransactionAmount - paymentTransaction.PaidAmount - }; - - return View(model); - } - - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CapturePaymentTransaction(string id) - { - var paymentTransaction = await _paymentTransactionService.GetById(id); - if (paymentTransaction == null) - return RedirectToAction("List", "PaymentTransaction"); - - if (paymentTransaction.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return RedirectToAction("List", "PaymentTransaction"); - - try - { - var errors = await _mediator.Send(new CaptureCommand { PaymentTransaction = paymentTransaction }); - - foreach (var error in errors) - Error(error); - - return RedirectToAction("Edit", "PaymentTransaction", new { id }); - } - catch (Exception exc) - { - //error - Error(exc, false); - return RedirectToAction("Edit", "PaymentTransaction", new { id }); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task MarkPaymentTransactionAsPaid(string id) - { - var paymentTransaction = await _paymentTransactionService.GetById(id); - if (paymentTransaction == null) - return RedirectToAction("List", "PaymentTransaction"); - - if (paymentTransaction.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return RedirectToAction("List", "PaymentTransaction"); - - try - { - await _mediator.Send(new MarkAsPaidCommand { PaymentTransaction = paymentTransaction }); - return RedirectToAction("Edit", "PaymentTransaction", new { id }); - } - catch (Exception exc) - { - //error - Error(exc, false); - return RedirectToAction("Edit", "PaymentTransaction", new { id }); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RefundPaymentTransaction(string id) - { - var paymentTransaction = await _paymentTransactionService.GetById(id); - if (paymentTransaction == null) - return RedirectToAction("List", "PaymentTransaction"); - - if (paymentTransaction.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return RedirectToAction("List", "PaymentTransaction"); - - try - { - var errors = await _mediator.Send(new RefundCommand { PaymentTransaction = paymentTransaction }); - foreach (var error in errors) - Error(error); - - return RedirectToAction("Edit", "PaymentTransaction", new { id }); - } - catch (Exception exc) - { - //error - Error(exc, false); - return RedirectToAction("Edit", "PaymentTransaction", new { id }); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RefundPaymentTransactionOffline(string id) - { - var paymentTransaction = await _paymentTransactionService.GetById(id); - if (paymentTransaction == null) - return RedirectToAction("List", "PaymentTransaction"); - - if (paymentTransaction.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return RedirectToAction("List", "PaymentTransaction"); - - try - { - await _mediator.Send(new RefundOfflineCommand { PaymentTransaction = paymentTransaction }); - return RedirectToAction("Edit", "PaymentTransaction", new { id }); - } - catch (Exception exc) - { - //error - Error(exc, false); - return RedirectToAction("Edit", "PaymentTransaction", new { id }); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task VoidPaymentTransaction(string id) - { - var paymentTransaction = await _paymentTransactionService.GetById(id); - if (paymentTransaction == null) - return RedirectToAction("List", "PaymentTransaction"); - - if (paymentTransaction.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return RedirectToAction("List", "PaymentTransaction"); - - try - { - var errors = await _mediator.Send(new VoidCommand { PaymentTransaction = paymentTransaction }); - foreach (var error in errors) - Error(error); - - return RedirectToAction("Edit", "PaymentTransaction", new { id }); - } - catch (Exception exc) - { - //error - Error(exc, false); - return RedirectToAction("Edit", "PaymentTransaction", new { id }); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task VoidPaymentTransactionOffline(string id) - { - var paymentTransaction = await _paymentTransactionService.GetById(id); - if (paymentTransaction == null) - return RedirectToAction("List", "PaymentTransaction"); - - if (paymentTransaction.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return RedirectToAction("List", "PaymentTransaction"); - - try - { - await _mediator.Send(new VoidOfflineCommand { PaymentTransaction = paymentTransaction }); - return RedirectToAction("Edit", "PaymentTransaction", new { id }); - } - catch (Exception exc) - { - //error - Error(exc, false); - return RedirectToAction("Edit", "PaymentTransaction", new { id }); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task PartiallyRefundPopup(string id, bool online) - { - var paymentTransaction = await _paymentTransactionService.GetById(id); - if (paymentTransaction == null) - return RedirectToAction("List", "PaymentTransaction"); - - if (paymentTransaction.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return RedirectToAction("List", "PaymentTransaction"); - - var model = new PaymentTransactionModel { - Id = paymentTransaction.Id, - MaxAmountToRefund = paymentTransaction.TransactionAmount - paymentTransaction.RefundedAmount, - CurrencyCode = paymentTransaction.CurrencyCode - }; - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task PartiallyRefundPopup(string id, bool online, PaymentTransactionModel model) - { - var paymentTransaction = await _paymentTransactionService.GetById(id); - if (paymentTransaction == null) - return RedirectToAction("List", "PaymentTransaction"); - - if (paymentTransaction.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return RedirectToAction("List", "PaymentTransaction"); - - try - { - var amountToRefund = model.AmountToRefund; - if (amountToRefund <= 0) - throw new GrandException("Enter amount to refund"); - - var maxAmountToRefund = paymentTransaction.TransactionAmount - paymentTransaction.RefundedAmount; - if (amountToRefund > maxAmountToRefund) - amountToRefund = maxAmountToRefund; - - var errors = new List(); - if (online) - errors = (await _mediator.Send(new PartiallyRefundCommand - { PaymentTransaction = paymentTransaction, AmountToRefund = amountToRefund })).ToList(); - else - await _mediator.Send(new PartiallyRefundOfflineCommand - { PaymentTransaction = paymentTransaction, AmountToRefund = amountToRefund }); - - if (errors.Count == 0) - { - //success - model.RefreshPage = true; - return View(model); - } - - //error - foreach (var error in errors) - Error(error); - - return View(model); - } - catch (Exception exc) - { - Error(exc, false); - return View(model); - } - } - - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task PartiallyPaidPopup(string id, bool online) - { - var paymentTransaction = await _paymentTransactionService.GetById(id); - if (paymentTransaction == null) - return RedirectToAction("List", "PaymentTransaction"); - - if (paymentTransaction.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return RedirectToAction("List", "PaymentTransaction"); - - var model = new PaymentTransactionModel { - Id = paymentTransaction.Id, - MaxAmountToPaid = paymentTransaction.TransactionAmount - paymentTransaction.PaidAmount, - CurrencyCode = paymentTransaction.CurrencyCode - }; - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task PartiallyPaidPopup(string id, bool online, PaymentTransactionModel model) - { - var paymentTransaction = await _paymentTransactionService.GetById(id); - if (paymentTransaction == null) - return RedirectToAction("List", "PaymentTransaction"); - - if (paymentTransaction.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return RedirectToAction("List", "PaymentTransaction"); - - try - { - var amountToPaid = model.AmountToPaid; - if (amountToPaid <= 0) - throw new GrandException("Enter amount to refund"); - - var maxAmountToPaid = paymentTransaction.TransactionAmount - paymentTransaction.PaidAmount; - if (amountToPaid > maxAmountToPaid) - amountToPaid = maxAmountToPaid; - - await _mediator.Send(new PartiallyPaidOfflineCommand - { PaymentTransaction = paymentTransaction, AmountToPaid = amountToPaid }); - - model.RefreshPage = true; - return View(model); - } - catch (Exception exc) - { - Error(exc, false); - return View(model); - } - } - - //delete - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task Delete(string id) - { - var paymentTransaction = await _paymentTransactionService.GetById(id); - if (paymentTransaction == null) - return RedirectToAction("List"); - - if (paymentTransaction.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) - return RedirectToAction("List", "MerchandiseReturn"); - - if (ModelState.IsValid) - { - await _paymentTransactionService.DeletePaymentTransaction(paymentTransaction); - Success(_translationService.GetResource("Admin.Orders.PaymentTransaction.Deleted")); - return RedirectToAction("List", "PaymentTransaction"); - } - - Error(ModelState); - return RedirectToAction("Edit", new { id = paymentTransaction.Id }); - } - - #endregion -} \ No newline at end of file +// Reduced to a thin subclass of BasePaymentTransactionController (ARCH-001 PaymentTransaction +// consolidation). All regions of behavior live in the shared base; this class only supplies +// Store's DI wiring and the attributes that used to arrive transitively via BaseStoreController. +// Same pattern as CollectionController/CategoryController (see those files). +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class PaymentTransactionController( + IPaymentTransactionService paymentTransactionService, + IOrderService orderService, + ITranslationService translationService, + IDateTimeService dateTimeService, + IMediator mediator, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) + : BasePaymentTransactionController(paymentTransactionService, orderService, translationService, + dateTimeService, mediator, enumTranslationService, scope); From 0fa7b2930aff852d69784e51737da7c21b685dfe Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Wed, 26 Aug 2026 09:08:20 +0200 Subject: [PATCH 6/8] refactor(arch001): migrate PaymentTransaction views to Grand.Web.AdminShared, extract per-host WidgetZone satellites Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013XGyrBT97Wr9zjxgDmG2KR --- .../PartiallyPaidPopup.cshtml | 79 ------- .../PartiallyRefundPopup.cshtml | 80 ------- .../Partials/WidgetZone.DetailsButtons.cshtml | 2 + .../Partials/WidgetZone.DetailsTop.cshtml | 2 + .../Partials/WidgetZone.ListButtons.cshtml | 2 + .../WidgetZone.PartiallyPaidButtons.cshtml | 2 + .../WidgetZone.PartiallyRefundButtons.cshtml | 2 + .../PaymentTransaction/Edit.cshtml | 15 +- .../PaymentTransaction/List.cshtml | 15 +- .../PartiallyPaidPopup.cshtml | 9 +- .../PartiallyRefundPopup.cshtml | 9 +- .../PaymentTransaction/Partials/Update.cshtml | 6 +- .../Views/PaymentTransaction/Edit.cshtml | 145 ------------- .../Views/PaymentTransaction/List.cshtml | 202 ------------------ .../PaymentTransaction/Partials/Update.cshtml | 135 ------------ .../Partials/WidgetZone.DetailsButtons.cshtml | 2 + .../Partials/WidgetZone.DetailsTop.cshtml | 2 + .../Partials/WidgetZone.ListButtons.cshtml | 2 + .../WidgetZone.PartiallyPaidButtons.cshtml | 2 + .../WidgetZone.PartiallyRefundButtons.cshtml | 2 + 20 files changed, 49 insertions(+), 666 deletions(-) delete mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/PartiallyPaidPopup.cshtml delete mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/PartiallyRefundPopup.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/Partials/WidgetZone.DetailsButtons.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/Partials/WidgetZone.DetailsTop.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/Partials/WidgetZone.ListButtons.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/Partials/WidgetZone.PartiallyPaidButtons.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/Partials/WidgetZone.PartiallyRefundButtons.cshtml rename src/Web/{Grand.Web.Admin/Areas/Admin/Views => Grand.Web.AdminShared/Views/AdminShared}/PaymentTransaction/Edit.cshtml (95%) rename src/Web/{Grand.Web.Admin/Areas/Admin/Views => Grand.Web.AdminShared/Views/AdminShared}/PaymentTransaction/List.cshtml (94%) rename src/Web/{Grand.Web.Store/Areas/Store/Views => Grand.Web.AdminShared/Views/AdminShared}/PaymentTransaction/PartiallyPaidPopup.cshtml (91%) rename src/Web/{Grand.Web.Store/Areas/Store/Views => Grand.Web.AdminShared/Views/AdminShared}/PaymentTransaction/PartiallyRefundPopup.cshtml (91%) rename src/Web/{Grand.Web.Admin/Areas/Admin/Views => Grand.Web.AdminShared/Views/AdminShared}/PaymentTransaction/Partials/Update.cshtml (97%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Edit.cshtml delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/List.cshtml delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/Update.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.DetailsButtons.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.DetailsTop.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.ListButtons.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.PartiallyPaidButtons.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.PartiallyRefundButtons.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/PartiallyPaidPopup.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/PartiallyPaidPopup.cshtml deleted file mode 100644 index dddb2cc72..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/PartiallyPaidPopup.cshtml +++ /dev/null @@ -1,79 +0,0 @@ -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Admin.PaymentTransaction.Fields.PartialPaid"]; -} -@model PaymentTransactionModel -
- - - - - -
-
-
-
-
- - @Loc["Admin.PaymentTransaction.Fields.PartialPaid"] -
-
- -
-
-
-
- -
-   - - @string.Format(Loc["Admin.PaymentTransaction.Fields.PartialPaid.AmountToPaid.Max"], Model.MaxAmountToPaid.ToString("N2"), Model.CurrencyCode) - -
-
-
-
- -
-
-
-
-
-
- -
- @if (ViewBag.RefreshPage == true) - { - - } - -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/PartiallyRefundPopup.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/PartiallyRefundPopup.cshtml deleted file mode 100644 index 7ea695379..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/PartiallyRefundPopup.cshtml +++ /dev/null @@ -1,80 +0,0 @@ -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Admin.PaymentTransaction.Fields.PartialRefund"]; -} -@model PaymentTransactionModel -
- - - - - -
-
-
-
-
- - @Loc["Admin.PaymentTransaction.Fields.PartialRefund"] -
-
- -
-
-
-
- -
-   - - @string.Format(Loc["Admin.PaymentTransaction.Fields.PartialRefund.AmountToRefund.Max"], Model.MaxAmountToRefund.ToString("G29"), Model.CurrencyCode) - -
-
-
-
- -
-
-
-
-
-
- -
- @if (ViewBag.RefreshPage == true) - { - - } - -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/Partials/WidgetZone.DetailsButtons.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/Partials/WidgetZone.DetailsButtons.cshtml new file mode 100644 index 000000000..ca1e9bf82 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/Partials/WidgetZone.DetailsButtons.cshtml @@ -0,0 +1,2 @@ +@model PaymentTransactionModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/Partials/WidgetZone.DetailsTop.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/Partials/WidgetZone.DetailsTop.cshtml new file mode 100644 index 000000000..27eec2737 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/Partials/WidgetZone.DetailsTop.cshtml @@ -0,0 +1,2 @@ +@model PaymentTransactionModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/Partials/WidgetZone.ListButtons.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/Partials/WidgetZone.ListButtons.cshtml new file mode 100644 index 000000000..257ca20a3 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/Partials/WidgetZone.ListButtons.cshtml @@ -0,0 +1,2 @@ +@model PaymentTransactionListModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/Partials/WidgetZone.PartiallyPaidButtons.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/Partials/WidgetZone.PartiallyPaidButtons.cshtml new file mode 100644 index 000000000..c4fb60d35 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/Partials/WidgetZone.PartiallyPaidButtons.cshtml @@ -0,0 +1,2 @@ +@model PaymentTransactionModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/Partials/WidgetZone.PartiallyRefundButtons.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/Partials/WidgetZone.PartiallyRefundButtons.cshtml new file mode 100644 index 000000000..e30d49474 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/Partials/WidgetZone.PartiallyRefundButtons.cshtml @@ -0,0 +1,2 @@ +@model PaymentTransactionModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/Edit.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/PaymentTransaction/Edit.cshtml similarity index 95% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/Edit.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/PaymentTransaction/Edit.cshtml index 1b31a8f57..fa0341c2b 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/Edit.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/PaymentTransaction/Edit.cshtml @@ -1,9 +1,10 @@ -@model PaymentTransactionModel +@model PaymentTransactionModel @{ //page title ViewBag.Title = Loc["Admin.Orders.PaymentTransaction.Details"]; + var area = ViewContext.RouteData.Values["area"]?.ToString(); } -
+
@@ -34,7 +35,7 @@ @if (Model.CanPartiallyPaidOffline) { @Loc["Admin.PaymentTransaction.Fields.PartialPaidOffline"] @@ -56,7 +57,7 @@ { @Loc["Admin.PaymentTransaction.Fields.PartialRefund"] @@ -65,7 +66,7 @@ @if (Model.CanPartiallyRefundOffline) { @Loc["Admin.PaymentTransaction.Fields.PartialRefundOffline"] @@ -88,7 +89,7 @@ @Loc["Admin.Common.Delete"] - +
@@ -142,4 +143,4 @@ });
- \ No newline at end of file + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/List.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/PaymentTransaction/List.cshtml similarity index 94% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/List.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/PaymentTransaction/List.cshtml index 428c987a1..ecc23b35f 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/List.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/PaymentTransaction/List.cshtml @@ -1,10 +1,11 @@ -@model PaymentTransactionListModel +@model PaymentTransactionListModel @inject AdminAreaSettings adminAreaSettings @{ //page title ViewBag.Title = Loc["Admin.Orders.PaymentTransaction"]; + var area = ViewContext.RouteData.Values["area"]?.ToString(); } -
+
@@ -14,7 +15,7 @@ @Loc["Admin.Orders.PaymentTransaction"]
- +
@@ -92,7 +93,7 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("List", "PaymentTransaction", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("List", "PaymentTransaction", new { area = area }))", type: "POST", dataType: "json", data: additionalData @@ -142,12 +143,12 @@ field: "CustomerId", title: "@Loc["Admin.Orders.PaymentTransaction.Fields.Customer"]", width: 200, - template: '#:kendo.htmlEncode(CustomerEmail)#' + template: '#:kendo.htmlEncode(CustomerEmail)#' }, { field: "OrderNumber", title: "@Loc["Admin.Orders.PaymentTransaction.Fields.OrderNumber"]", width: 80, - template: '#:OrderNumber#' + template: '#:OrderNumber#' }, { field: "Status", title: "@Loc["Admin.Orders.PaymentTransaction.Fields.Status"]", @@ -199,4 +200,4 @@ } }); }); - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/PartiallyPaidPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/PaymentTransaction/PartiallyPaidPopup.cshtml similarity index 91% rename from src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/PartiallyPaidPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/PaymentTransaction/PartiallyPaidPopup.cshtml index c1bab3fb9..b421c509d 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/PartiallyPaidPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/PaymentTransaction/PartiallyPaidPopup.cshtml @@ -1,10 +1,11 @@ -@{ +@{ Layout = ""; //page title ViewBag.Title = Loc["Admin.PaymentTransaction.Fields.PartialPaid"]; + var area = ViewContext.RouteData.Values["area"]?.ToString(); } @model PaymentTransactionModel -
- +
@@ -76,4 +77,4 @@ }); }); - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/PartiallyRefundPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/PaymentTransaction/PartiallyRefundPopup.cshtml similarity index 91% rename from src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/PartiallyRefundPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/PaymentTransaction/PartiallyRefundPopup.cshtml index 34188d004..b909ac7ed 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/PartiallyRefundPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/PaymentTransaction/PartiallyRefundPopup.cshtml @@ -1,10 +1,11 @@ -@{ +@{ Layout = ""; //page title ViewBag.Title = Loc["Admin.PaymentTransaction.Fields.PartialRefund"]; + var area = ViewContext.RouteData.Values["area"]?.ToString(); } @model PaymentTransactionModel -
- +
@@ -77,4 +78,4 @@ }); }); - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/Partials/Update.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/PaymentTransaction/Partials/Update.cshtml similarity index 97% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/Partials/Update.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/PaymentTransaction/Partials/Update.cshtml index 47718e874..e1b96823d 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/PaymentTransaction/Partials/Update.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/PaymentTransaction/Partials/Update.cshtml @@ -1,11 +1,11 @@ -@model PaymentTransactionModel +@model PaymentTransactionModel
- +
@@ -132,4 +132,4 @@
}
-
\ No newline at end of file +
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Edit.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Edit.cshtml deleted file mode 100644 index bbb1f63db..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Edit.cshtml +++ /dev/null @@ -1,145 +0,0 @@ -@model PaymentTransactionModel -@{ - //page title - ViewBag.Title = Loc["Admin.Orders.PaymentTransaction.Details"]; -} -
- -
-
-
-
-
- - @Loc["Admin.Orders.PaymentTransaction.Details"] - - @Html.ActionLink(Loc["Admin.Orders.PaymentTransaction.BackToList"], "List") - -
-
-
- @if (Model.CanCapture) - { - -   - } - @if (Model.CanMarkAsPaid) - { - - } - @if (Model.CanPartiallyPaidOffline) - { - - @Loc["Admin.PaymentTransaction.Fields.PartialPaidOffline"] - -   - } - @if (Model.CanRefund) - { - -   - } - @if (Model.CanRefundOffline) - { - -   - } - @if (Model.CanPartiallyRefund) - { - - @Loc["Admin.PaymentTransaction.Fields.PartialRefund"] - -   - } - @if (Model.CanPartiallyRefundOffline) - { - - @Loc["Admin.PaymentTransaction.Fields.PartialRefundOffline"] - -   - } - @if (Model.CanVoid) - { - -   - } - @if (Model.CanVoidOffline) - { - -   - } - - @Loc["Admin.Common.Delete"] - - -
-
-
-
- -
-
-
-
- -
- \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/List.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/List.cshtml deleted file mode 100644 index 069bb29b3..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/List.cshtml +++ /dev/null @@ -1,202 +0,0 @@ -@model PaymentTransactionListModel -@inject AdminAreaSettings adminAreaSettings -@{ - //page title - ViewBag.Title = Loc["Admin.Orders.PaymentTransaction"]; -} -
- -
-
- -
-
-
- - - \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/Update.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/Update.cshtml deleted file mode 100644 index 47718e874..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/Update.cshtml +++ /dev/null @@ -1,135 +0,0 @@ -@model PaymentTransactionModel - -
- - - -
- -
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
- @if (Model.RefundedAmount > 0) - { -
- -
- -
-
- } -
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
- @if (!string.IsNullOrEmpty(Model.AdditionalInfo)) - { -
- -
- -
-
- } - @if (!string.IsNullOrEmpty(Model.Description)) - { -
- -
- -
-
- } - @if (!string.IsNullOrEmpty(Model.CaptureTransactionId)) - { -
- -
- -
-
- } - @if (!string.IsNullOrEmpty(Model.CaptureTransactionResult)) - { -
- -
- -
-
- } - @if (!string.IsNullOrEmpty(Model.AuthorizationTransactionId)) - { -
- -
- -
-
- } - @if (!string.IsNullOrEmpty(Model.AuthorizationTransactionCode)) - { -
- -
- -
-
- } - @if (!string.IsNullOrEmpty(Model.AuthorizationTransactionResult)) - { -
- -
- -
-
- } -
-
\ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.DetailsButtons.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.DetailsButtons.cshtml new file mode 100644 index 000000000..c512d49ae --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.DetailsButtons.cshtml @@ -0,0 +1,2 @@ +@model PaymentTransactionModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.DetailsTop.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.DetailsTop.cshtml new file mode 100644 index 000000000..5f9095607 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.DetailsTop.cshtml @@ -0,0 +1,2 @@ +@model PaymentTransactionModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.ListButtons.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.ListButtons.cshtml new file mode 100644 index 000000000..be8ad217a --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.ListButtons.cshtml @@ -0,0 +1,2 @@ +@model PaymentTransactionListModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.PartiallyPaidButtons.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.PartiallyPaidButtons.cshtml new file mode 100644 index 000000000..f7413df81 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.PartiallyPaidButtons.cshtml @@ -0,0 +1,2 @@ +@model PaymentTransactionModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.PartiallyRefundButtons.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.PartiallyRefundButtons.cshtml new file mode 100644 index 000000000..a6d12018b --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.PartiallyRefundButtons.cshtml @@ -0,0 +1,2 @@ +@model PaymentTransactionModel + From 73c02e9db7eb54b897879f487eb097dd579edbaa Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Wed, 26 Aug 2026 14:39:16 +0200 Subject: [PATCH 7/8] =?UTF-8?q?fix(arch001):=20address=20final=20review=20?= =?UTF-8?q?findings=20=E2=80=94=20disclose=20widget-tag=20fix,=20strengthe?= =?UTF-8?q?n=20Admin=20routing=20test,=20add=20popup=20denial=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add in-tree Razor comment to the 5 Store PaymentTransaction WidgetZone partials explaining the vc:store-widget vs vc:admin-widget fix (Finding 1) - Strengthen Admin's PaymentTransactionControllerRoutingTests to assert [Area(Constants.AreaAdmin)] and AuthorizeAdminAttribute, mirroring Store's equivalent test (Finding 2) - Add denied-branch tests for PartiallyRefundPopup and PartiallyPaidPopup POST overloads in BasePaymentTransactionControllerTests (Finding 3) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013XGyrBT97Wr9zjxgDmG2KR --- .../BasePaymentTransactionControllerTests.cs | 28 +++++++++++++++++++ ...aymentTransactionControllerRoutingTests.cs | 16 +++++++++++ .../Partials/WidgetZone.DetailsButtons.cshtml | 3 ++ .../Partials/WidgetZone.DetailsTop.cshtml | 3 ++ .../Partials/WidgetZone.ListButtons.cshtml | 3 ++ .../WidgetZone.PartiallyPaidButtons.cshtml | 3 ++ .../WidgetZone.PartiallyRefundButtons.cshtml | 3 ++ 7 files changed, 59 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BasePaymentTransactionControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BasePaymentTransactionControllerTests.cs index 5364764e3..4972e54a4 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BasePaymentTransactionControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BasePaymentTransactionControllerTests.cs @@ -238,6 +238,20 @@ public async Task PartiallyRefundPopup_Post_ZeroAmount_ErrorsAndDoesNotSetRefres Assert.IsFalse(resultModel.RefreshPage); } + [TestMethod] + public async Task PartiallyRefundPopup_Post_DeniedByScope_RedirectsToList() + { + _paymentTransactionServiceMock.Setup(s => s.GetById("pt-1")).ReturnsAsync(new PaymentTransaction { Id = "pt-1" }); + _scopeMock.Setup(s => s.HasAccess(It.IsAny())).ReturnsAsync(false); + + var model = new PaymentTransactionModel { AmountToRefund = 50 }; + var result = await _controller.PartiallyRefundPopup("pt-1", false, model) as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("List", result.ActionName); + _mediatorMock.Verify(m => m.Send(It.IsAny(), default), Times.Never); + } + [TestMethod] public async Task PartiallyPaidPopup_Post_Success_SetsModelRefreshPage() { @@ -253,6 +267,20 @@ public async Task PartiallyPaidPopup_Post_Success_SetsModelRefreshPage() Assert.IsTrue(resultModel.RefreshPage); } + [TestMethod] + public async Task PartiallyPaidPopup_Post_DeniedByScope_RedirectsToList() + { + _paymentTransactionServiceMock.Setup(s => s.GetById("pt-1")).ReturnsAsync(new PaymentTransaction { Id = "pt-1" }); + _scopeMock.Setup(s => s.HasAccess(It.IsAny())).ReturnsAsync(false); + + var model = new PaymentTransactionModel { AmountToPaid = 50 }; + var result = await _controller.PartiallyPaidPopup("pt-1", false, model) as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("List", result.ActionName); + _mediatorMock.Verify(m => m.Send(It.IsAny(), default), Times.Never); + } + [TestMethod] public async Task Delete_DeniedByScope_RedirectsToList() { diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/PaymentTransactionControllerRoutingTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/PaymentTransactionControllerRoutingTests.cs index eb8774f5b..d5d972979 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/PaymentTransactionControllerRoutingTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/PaymentTransactionControllerRoutingTests.cs @@ -1,5 +1,8 @@ using Grand.Web.Admin.Controllers; +using Grand.Web.Admin.Extensions; using Grand.Web.AdminShared.Controllers; +using Grand.Web.Common.Filters; +using Microsoft.AspNetCore.Mvc; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Grand.Web.Admin.Tests.Controllers; @@ -16,4 +19,17 @@ public void AdminPaymentTransactionController_HasAutoValidateAntiforgeryToken() Assert.IsTrue(typeof(PaymentTransactionController) .GetCustomAttributes(typeof(Microsoft.AspNetCore.Mvc.AutoValidateAntiforgeryTokenAttribute), false) .Length > 0); + + [TestMethod] + public void AdminPaymentTransactionController_HasAreaAttributeWithAdminArea() + { + var areaAttr = (AreaAttribute)Attribute.GetCustomAttribute(typeof(PaymentTransactionController), typeof(AreaAttribute), false); + Assert.IsNotNull(areaAttr, "Missing [Area]."); + Assert.AreEqual(Constants.AreaAdmin, areaAttr.RouteValue); + } + + [TestMethod] + public void AdminPaymentTransactionController_HasAuthorizeAdminAttribute() => + Assert.IsTrue(typeof(PaymentTransactionController).IsDefined(typeof(AuthorizeAdminAttribute), false), + "Missing [AuthorizeAdmin]."); } diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.DetailsButtons.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.DetailsButtons.cshtml index c512d49ae..e7a214290 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.DetailsButtons.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.DetailsButtons.cshtml @@ -1,2 +1,5 @@ +@* vc:store-widget, not vc:admin-widget as the pre-consolidation Store views used — that tag helper + is not registered in the Store app (no ProjectReference to Grand.Web.Admin); the original markup + was inert and never rendered anything. Fixed during ARCH-001 Phase 7 consolidation. *@ @model PaymentTransactionModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.DetailsTop.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.DetailsTop.cshtml index 5f9095607..918729f01 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.DetailsTop.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.DetailsTop.cshtml @@ -1,2 +1,5 @@ +@* vc:store-widget, not vc:admin-widget as the pre-consolidation Store views used — that tag helper + is not registered in the Store app (no ProjectReference to Grand.Web.Admin); the original markup + was inert and never rendered anything. Fixed during ARCH-001 Phase 7 consolidation. *@ @model PaymentTransactionModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.ListButtons.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.ListButtons.cshtml index be8ad217a..efbdddfb1 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.ListButtons.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.ListButtons.cshtml @@ -1,2 +1,5 @@ +@* vc:store-widget, not vc:admin-widget as the pre-consolidation Store views used — that tag helper + is not registered in the Store app (no ProjectReference to Grand.Web.Admin); the original markup + was inert and never rendered anything. Fixed during ARCH-001 Phase 7 consolidation. *@ @model PaymentTransactionListModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.PartiallyPaidButtons.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.PartiallyPaidButtons.cshtml index f7413df81..c99097c5e 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.PartiallyPaidButtons.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.PartiallyPaidButtons.cshtml @@ -1,2 +1,5 @@ +@* vc:store-widget, not vc:admin-widget as the pre-consolidation Store views used — that tag helper + is not registered in the Store app (no ProjectReference to Grand.Web.Admin); the original markup + was inert and never rendered anything. Fixed during ARCH-001 Phase 7 consolidation. *@ @model PaymentTransactionModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.PartiallyRefundButtons.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.PartiallyRefundButtons.cshtml index a6d12018b..9ae13a475 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.PartiallyRefundButtons.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/Partials/WidgetZone.PartiallyRefundButtons.cshtml @@ -1,2 +1,5 @@ +@* vc:store-widget, not vc:admin-widget as the pre-consolidation Store views used — that tag helper + is not registered in the Store app (no ProjectReference to Grand.Web.Admin); the original markup + was inert and never rendered anything. Fixed during ARCH-001 Phase 7 consolidation. *@ @model PaymentTransactionModel From 245fcd71eca7de530cba02c641ee3ed34aa450dc Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Wed, 26 Aug 2026 17:38:58 +0200 Subject: [PATCH 8/8] fix(arch001): address CodeQL PR review findings on PaymentTransaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add [AutoValidateAntiforgeryToken] to BasePaymentTransactionController itself, matching every other Base*Controller in this codebase (BaseCollectionController, BaseOrderController, BaseShipmentController). Runtime CSRF protection was already in place via the attribute restated on both concrete Admin/Store subclasses, so this is not a functional fix — but CodeQL's cross-project analysis doesn't follow the attribute across the base/derived project boundary, which is exactly the false-positive class BaseCollectionController's own doc comment already calls out and works around the same way. Resolves CodeQL alerts #1503-1513 (List, GoToOrderNumber, Capture/MarkAsPaid/Refund/RefundOffline/Void/VoidOffline, PartiallyRefundPopup, PartiallyPaidPopup, Delete). - Add null-forgiving (!) operators after Assert.IsNotNull(...) guards throughout BasePaymentTransactionControllerTests.cs. MSTest's Assert.IsNotNull narrows at runtime but CodeQL's C# nullable-flow analysis doesn't recognize it as a null-check, flagging the subsequent property access as a possible null dereference. Test-code-only, no behavior change; resolves 10 github-code-quality alerts plus 2 unreported instances of the identical pattern found by inspection. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013XGyrBT97Wr9zjxgDmG2KR --- .../BasePaymentTransactionControllerTests.cs | 40 +++++++++---------- .../BasePaymentTransactionController.cs | 1 + 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BasePaymentTransactionControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BasePaymentTransactionControllerTests.cs index 4972e54a4..2b0710ace 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BasePaymentTransactionControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BasePaymentTransactionControllerTests.cs @@ -96,7 +96,7 @@ public void List_ReturnsViewWithPreparedModel() var result = _controller.List() as ViewResult; Assert.IsNotNull(result); - Assert.IsInstanceOfType(result.Model, typeof(PaymentTransactionListModel)); + Assert.IsInstanceOfType(result!.Model, typeof(PaymentTransactionListModel)); } [TestMethod] @@ -138,7 +138,7 @@ public async Task GoToOrderNumber_DeniedByScope_RedirectsToList() var result = await _controller.GoToOrderNumber(new PaymentTransactionListModel { OrderNumber = "123" }) as RedirectToActionResult; Assert.IsNotNull(result); - Assert.AreEqual("List", result.ActionName); + Assert.AreEqual("List", result!.ActionName); } [TestMethod] @@ -151,8 +151,8 @@ public async Task GoToOrderNumber_Authorized_RedirectsToEdit() var result = await _controller.GoToOrderNumber(new PaymentTransactionListModel { OrderNumber = "123" }) as RedirectToActionResult; Assert.IsNotNull(result); - Assert.AreEqual("Edit", result.ActionName); - Assert.AreEqual("pt-1", result.RouteValues["id"]); + Assert.AreEqual("Edit", result!.ActionName); + Assert.AreEqual("pt-1", result!.RouteValues["id"]); } [TestMethod] @@ -164,7 +164,7 @@ public async Task Edit_DeniedByScope_RedirectsToList() var result = await _controller.Edit("pt-1") as RedirectToActionResult; Assert.IsNotNull(result); - Assert.AreEqual("List", result.ActionName); + Assert.AreEqual("List", result!.ActionName); } [TestMethod] @@ -177,9 +177,9 @@ public async Task Edit_Authorized_ReturnsViewWithModel() var result = await _controller.Edit("pt-1") as ViewResult; Assert.IsNotNull(result); - var model = result.Model as PaymentTransactionModel; + var model = result!.Model as PaymentTransactionModel; Assert.IsNotNull(model); - Assert.AreEqual("pt-1", model.Id); + Assert.AreEqual("pt-1", model!.Id); Assert.AreEqual(80, model.MaxAmountToRefund); } @@ -192,7 +192,7 @@ public async Task CapturePaymentTransaction_DeniedByScope_RedirectsToList() var result = await _controller.CapturePaymentTransaction("pt-1") as RedirectToActionResult; Assert.IsNotNull(result); - Assert.AreEqual("List", result.ActionName); + Assert.AreEqual("List", result!.ActionName); } [TestMethod] @@ -205,8 +205,8 @@ public async Task CapturePaymentTransaction_Authorized_SendsCaptureCommandAndRed var result = await _controller.CapturePaymentTransaction("pt-1") as RedirectToActionResult; Assert.IsNotNull(result); - Assert.AreEqual("Edit", result.ActionName); - Assert.AreEqual("pt-1", result.RouteValues["id"]); + Assert.AreEqual("Edit", result!.ActionName); + Assert.AreEqual("pt-1", result!.RouteValues["id"]); } [TestMethod] @@ -220,8 +220,8 @@ public async Task PartiallyRefundPopup_Post_Success_SetsModelRefreshPage() var result = await _controller.PartiallyRefundPopup("pt-1", false, model) as ViewResult; Assert.IsNotNull(result); - var resultModel = result.Model as PaymentTransactionModel; - Assert.IsTrue(resultModel.RefreshPage); + var resultModel = result!.Model as PaymentTransactionModel; + Assert.IsTrue(resultModel!.RefreshPage); } [TestMethod] @@ -234,8 +234,8 @@ public async Task PartiallyRefundPopup_Post_ZeroAmount_ErrorsAndDoesNotSetRefres var result = await _controller.PartiallyRefundPopup("pt-1", false, model) as ViewResult; Assert.IsNotNull(result); - var resultModel = result.Model as PaymentTransactionModel; - Assert.IsFalse(resultModel.RefreshPage); + var resultModel = result!.Model as PaymentTransactionModel; + Assert.IsFalse(resultModel!.RefreshPage); } [TestMethod] @@ -248,7 +248,7 @@ public async Task PartiallyRefundPopup_Post_DeniedByScope_RedirectsToList() var result = await _controller.PartiallyRefundPopup("pt-1", false, model) as RedirectToActionResult; Assert.IsNotNull(result); - Assert.AreEqual("List", result.ActionName); + Assert.AreEqual("List", result!.ActionName); _mediatorMock.Verify(m => m.Send(It.IsAny(), default), Times.Never); } @@ -263,8 +263,8 @@ public async Task PartiallyPaidPopup_Post_Success_SetsModelRefreshPage() var result = await _controller.PartiallyPaidPopup("pt-1", false, model) as ViewResult; Assert.IsNotNull(result); - var resultModel = result.Model as PaymentTransactionModel; - Assert.IsTrue(resultModel.RefreshPage); + var resultModel = result!.Model as PaymentTransactionModel; + Assert.IsTrue(resultModel!.RefreshPage); } [TestMethod] @@ -277,7 +277,7 @@ public async Task PartiallyPaidPopup_Post_DeniedByScope_RedirectsToList() var result = await _controller.PartiallyPaidPopup("pt-1", false, model) as RedirectToActionResult; Assert.IsNotNull(result); - Assert.AreEqual("List", result.ActionName); + Assert.AreEqual("List", result!.ActionName); _mediatorMock.Verify(m => m.Send(It.IsAny(), default), Times.Never); } @@ -290,7 +290,7 @@ public async Task Delete_DeniedByScope_RedirectsToList() var result = await _controller.Delete("pt-1") as RedirectToActionResult; Assert.IsNotNull(result); - Assert.AreEqual("List", result.ActionName); + Assert.AreEqual("List", result!.ActionName); } [TestMethod] @@ -302,7 +302,7 @@ public async Task Delete_Authorized_DeletesAndRedirectsToList() var result = await _controller.Delete("pt-1") as RedirectToActionResult; Assert.IsNotNull(result); - Assert.AreEqual("List", result.ActionName); + Assert.AreEqual("List", result!.ActionName); _paymentTransactionServiceMock.Verify(s => s.DeletePaymentTransaction(transaction), Times.Once); } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BasePaymentTransactionController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BasePaymentTransactionController.cs index 4e833f5f0..7e8531fc1 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BasePaymentTransactionController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BasePaymentTransactionController.cs @@ -20,6 +20,7 @@ namespace Grand.Web.AdminShared.Controllers; [PermissionAuthorize(PermissionSystemName.PaymentTransactions)] +[AutoValidateAntiforgeryToken] public abstract class BasePaymentTransactionController( IPaymentTransactionService paymentTransactionService, IOrderService orderService,