From 1acbf208fe1a51622e52432faca369a0d35f7f86 Mon Sep 17 00:00:00 2001 From: support Date: Sat, 24 Oct 2026 12:12:07 +0200 Subject: [PATCH 01/28] Keep storefront sub-account management behind its setting and owner Hiding the Subaccounts tab only removed the link: the add, edit and delete actions stayed reachable by URL, and a fresh install hides the tab by default. Every sub-account action now goes through one check, so the setting switches the feature off. The edit command trusted the validator for ownership; it now re-checks it and changes nothing for someone else's, a deleted or a missing account. The edit page answers such an id with a redirect instead of an empty form, the validator gives a missing id and a foreign one the same answer, and the delete endpoint returns the handler's message instead of an empty one. A changed e-mail was stored as typed while sign-in looks it up lowercased, so a sub-account whose owner typed capitals could no longer sign in, and a new password saved together with it failed. The e-mail is stored lowercased and, without usernames, the username follows it. Inactive sub-accounts are marked on the owner's list. Hard-coded messages became resources (also the admin ones used by the next commit). --- .../SubAccountDeleteCommandHandlerTests.cs | 70 +++++++ .../SubAccountEditCommandHandlerTests.cs | 155 +++++++++++++-- .../AccountControllerSubAccountsTests.cs | 188 ++++++++++++++++++ .../Customers/GetSubAccountHandlerTests.cs | 58 ++++++ .../CustomerValidatorsStoreScopingTests.cs | 4 +- .../Customer/SubAccountEditValidatorTests.cs | 121 +++++++++++ .../App_Data/Resources/DefaultLanguage.xml | Bin 1590302 -> 1592738 bytes .../App_Data/Resources/Upgrade/en_250.xml | 24 +++ .../SubAccountDeleteCommandHandler.cs | 10 +- .../Customers/SubAccountEditCommandHandler.cs | 17 +- .../Controllers/AccountController.cs | 53 ++--- .../Customers/GetSubAccountHandler.cs | 21 +- .../Customer/SubAccountEditValidator.cs | 26 ++- .../Views/Account/SubAccounts.cshtml | 4 + 14 files changed, 677 insertions(+), 74 deletions(-) create mode 100644 src/Tests/Grand.Web.Tests/Commands/Customers/SubAccountDeleteCommandHandlerTests.cs create mode 100644 src/Tests/Grand.Web.Tests/Controllers/AccountControllerSubAccountsTests.cs create mode 100644 src/Tests/Grand.Web.Tests/Features/Customers/GetSubAccountHandlerTests.cs create mode 100644 src/Tests/Grand.Web.Tests/Validators/Customer/SubAccountEditValidatorTests.cs diff --git a/src/Tests/Grand.Web.Tests/Commands/Customers/SubAccountDeleteCommandHandlerTests.cs b/src/Tests/Grand.Web.Tests/Commands/Customers/SubAccountDeleteCommandHandlerTests.cs new file mode 100644 index 0000000000..393b0d8876 --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Commands/Customers/SubAccountDeleteCommandHandlerTests.cs @@ -0,0 +1,70 @@ +using Grand.Business.Core.Interfaces.Customers; +using Grand.Domain.Customers; +using Grand.Web.Commands.Handler.Customers; +using Grand.Web.Commands.Models.Customers; +using Grand.Web.Common.Localization; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Tests.Commands.Customers; + +[TestClass] +public class SubAccountDeleteCommandHandlerTests +{ + private Mock _customerServiceMock; + private SubAccountDeleteCommandHandler _handler; + + [TestInitialize] + public void Init() + { + _customerServiceMock = new Mock(); + var translation = new Mock(); + translation.Setup(t => t.GetResource(It.IsAny())).Returns(key => key); + _handler = new SubAccountDeleteCommandHandler(_customerServiceMock.Object, translation.Object); + } + + private static SubAccountDeleteCommand Command(string id) => + new() { CurrentCustomer = new Customer { Id = "owner" }, CustomerId = id }; + + [TestMethod] + public async Task Handle_OwnSubAccount_DeletesIt() + { + var subAccount = new Customer { Id = "sub", OwnerId = "owner" }; + _customerServiceMock.Setup(c => c.GetCustomerById("sub")).ReturnsAsync(subAccount); + + var (success, error) = await _handler.Handle(Command("sub"), default); + + Assert.IsTrue(success); + Assert.AreEqual(string.Empty, error); + _customerServiceMock.Verify(c => c.DeleteCustomer(subAccount, false), Times.Once); + } + + [TestMethod] + [DataRow("another-owner")] + [DataRow("")] + public async Task Handle_SomeoneElsesAccount_DeletesNothing(string ownerId) + { + _customerServiceMock.Setup(c => c.GetCustomerById("victim")) + .ReturnsAsync(new Customer { Id = "victim", OwnerId = ownerId }); + + var (success, error) = await _handler.Handle(Command("victim"), default); + + Assert.IsFalse(success); + Assert.AreEqual("Account.SubAccounts.NotOwner", error); + _customerServiceMock.Verify(c => c.DeleteCustomer(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Handle_MissingOrDeletedAccount_GivesTheSameAnswer() + { + _customerServiceMock.Setup(c => c.GetCustomerById("gone")) + .ReturnsAsync(new Customer { Id = "gone", OwnerId = "owner", Deleted = true }); + + var deleted = await _handler.Handle(Command("gone"), default); + var missing = await _handler.Handle(Command("missing"), default); + + Assert.AreEqual(missing, deleted); + Assert.IsFalse(deleted.success); + _customerServiceMock.Verify(c => c.DeleteCustomer(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/src/Tests/Grand.Web.Tests/Commands/Customers/SubAccountEditCommandHandlerTests.cs b/src/Tests/Grand.Web.Tests/Commands/Customers/SubAccountEditCommandHandlerTests.cs index 39337c109b..a27f7fea6d 100644 --- a/src/Tests/Grand.Web.Tests/Commands/Customers/SubAccountEditCommandHandlerTests.cs +++ b/src/Tests/Grand.Web.Tests/Commands/Customers/SubAccountEditCommandHandlerTests.cs @@ -6,6 +6,7 @@ using Grand.Web.Models.Customer; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; +using System.Linq.Expressions; namespace Grand.Web.Tests.Commands.Customers; @@ -14,6 +15,7 @@ public class SubAccountEditCommandHandlerTests { private Mock _customerServiceMock; private Mock _customerManagerServiceMock; + private CustomerSettings _customerSettings; private SubAccountEditCommandHandler _handler; [TestInitialize] @@ -21,25 +23,44 @@ public void Init() { _customerServiceMock = new Mock(); _customerManagerServiceMock = new Mock(); + _customerSettings = new CustomerSettings { AllowUsersToChangeEmail = true }; _handler = new SubAccountEditCommandHandler(_customerServiceMock.Object, - _customerManagerServiceMock.Object, new CustomerSettings { AllowUsersToChangeEmail = true }); + _customerManagerServiceMock.Object, _customerSettings); } - [TestMethod] - public async Task Handle_WithPassword_ChangesPasswordScopedToCustomerStore() + private Customer SubAccount(string ownerId = "owner") { - var customer = new Customer { Id = "c1", Email = "sub@x.com", StoreId = "store-9" }; + var customer = new Customer { + Id = "c1", Email = "sub@x.com", Username = "sub@x.com", StoreId = "store-9", OwnerId = ownerId + }; _customerServiceMock.Setup(c => c.GetCustomerById("c1")).ReturnsAsync(customer); + return customer; + } - var command = new SubAccountEditCommand { - CurrentCustomer = new Customer { Id = "owner" }, - EditModel = new SubAccountEditModel { - Id = "c1", Email = "sub@x.com", Password = "newpass", Active = true, - FirstName = "John", LastName = "Doe" - } - }; + private static SubAccountEditCommand Command(string email = "sub@x.com", string password = null) => new() { + CurrentCustomer = new Customer { Id = "owner" }, + EditModel = new SubAccountEditModel { + Id = "c1", Email = email, Password = password, Active = true, FirstName = "John", LastName = "Doe" + } + }; + + private void VerifyNothingWritten() + { + _customerServiceMock.Verify(c => c.UpdateCustomerField(It.IsAny(), + It.IsAny>>(), It.IsAny()), Times.Never); + _customerServiceMock.Verify(c => c.UpdateActive(It.IsAny()), Times.Never); + _customerServiceMock.Verify(c => c.UpdateUserField(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + _customerManagerServiceMock.Verify(m => m.ChangePassword(It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Handle_WithPassword_ChangesPasswordScopedToCustomerStore() + { + SubAccount(); - var result = await _handler.Handle(command, default); + var result = await _handler.Handle(Command(password: "newpass"), default); Assert.IsTrue(result); //the sub-account's store must scope the password change (safe with or without per-store identity) @@ -50,18 +71,112 @@ public async Task Handle_WithPassword_ChangesPasswordScopedToCustomerStore() [TestMethod] public async Task Handle_WithoutPassword_DoesNotChangePassword() { - var customer = new Customer { Id = "c1", Email = "sub@x.com", StoreId = "store-9" }; - _customerServiceMock.Setup(c => c.GetCustomerById("c1")).ReturnsAsync(customer); + SubAccount(); - var command = new SubAccountEditCommand { - CurrentCustomer = new Customer { Id = "owner" }, - EditModel = new SubAccountEditModel { Id = "c1", Email = "sub@x.com", Active = true } - }; - - var result = await _handler.Handle(command, default); + var result = await _handler.Handle(Command(), default); Assert.IsTrue(result); _customerManagerServiceMock.Verify(m => m.ChangePassword( It.IsAny(), It.IsAny()), Times.Never); } + + [TestMethod] + public async Task Handle_SomeoneElsesAccount_ChangesNothing() + { + SubAccount(ownerId: "another-owner"); + + var result = await _handler.Handle(Command(email: "taken@x.com", password: "newpass"), default); + + Assert.IsFalse(result); + VerifyNothingWritten(); + } + + [TestMethod] + public async Task Handle_AccountWithoutOwner_ChangesNothing() + { + //any main account's id, the current owner's own included: it has no owner, so it is nobody's sub-account + SubAccount(ownerId: ""); + + var result = await _handler.Handle(Command(), default); + + Assert.IsFalse(result); + VerifyNothingWritten(); + } + + [TestMethod] + public async Task Handle_DeletedAccount_ChangesNothing() + { + SubAccount().Deleted = true; + + var result = await _handler.Handle(Command(), default); + + Assert.IsFalse(result); + VerifyNothingWritten(); + } + + [TestMethod] + public async Task Handle_MissingAccount_ChangesNothing() + { + var result = await _handler.Handle(Command(), default); + + Assert.IsFalse(result); + VerifyNothingWritten(); + } + + [TestMethod] + public async Task Handle_MixedCaseEmail_IsStoredLowercased() + { + //stored as typed, the lowercased login lookup missed it and the sub-account could no longer sign in + var customer = SubAccount(); + + await _handler.Handle(Command(email: " New.Sub@X.com "), default); + + Assert.AreEqual("new.sub@x.com", customer.Email); + _customerServiceMock.Verify(c => c.UpdateCustomerField(customer, + It.IsAny>>(), "new.sub@x.com"), Times.AtLeastOnce); + } + + [TestMethod] + public async Task Handle_NewEmailAndPassword_ChangesPasswordOfTheNewEmail() + { + SubAccount(); + + await _handler.Handle(Command(email: "New@X.com", password: "newpass"), default); + + _customerManagerServiceMock.Verify(m => m.ChangePassword( + It.Is(r => r.Email == "new@x.com"), "store-9"), Times.Once); + } + + [TestMethod] + public async Task Handle_NewEmail_UsernamesDisabled_KeepsUsernameEqualToEmail() + { + var customer = SubAccount(); + + await _handler.Handle(Command(email: "new@x.com"), default); + + Assert.AreEqual("new@x.com", customer.Username); + } + + [TestMethod] + public async Task Handle_NewEmail_UsernamesEnabled_LeavesUsername() + { + _customerSettings.UsernamesEnabled = true; + var customer = SubAccount(); + + await _handler.Handle(Command(email: "new@x.com"), default); + + Assert.AreEqual("new@x.com", customer.Email); + Assert.AreEqual("sub@x.com", customer.Username); + } + + [TestMethod] + public async Task Handle_EmailChangeNotAllowed_KeepsEmail() + { + _customerSettings.AllowUsersToChangeEmail = false; + var customer = SubAccount(); + + await _handler.Handle(Command(email: "new@x.com"), default); + + Assert.AreEqual("sub@x.com", customer.Email); + } } diff --git a/src/Tests/Grand.Web.Tests/Controllers/AccountControllerSubAccountsTests.cs b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerSubAccountsTests.cs new file mode 100644 index 0000000000..b96c8bfde4 --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Controllers/AccountControllerSubAccountsTests.cs @@ -0,0 +1,188 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.Messages; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; +using Grand.Mediator; +using Grand.Web.Commands.Models.Customers; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authentication; +using Grand.Web.Controllers; +using Grand.Web.Features.Models.Customers; +using Grand.Web.Models.Customer; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Tests.Controllers; + +[TestClass] +public class AccountControllerSubAccountsTests +{ + private Customer _currentCustomer; + private CustomerSettings _customerSettings; + private Mock _mediator; + private AccountController _controller; + + [TestInitialize] + public void Init() + { + _currentCustomer = new Customer { Id = "owner" }; + var work = new Mock(); + work.Setup(w => w.CurrentCustomer).Returns(() => _currentCustomer); + var store = new Mock(); + store.Setup(s => s.CurrentStore).Returns(new Grand.Domain.Stores.Store { Id = "s1" }); + var context = new Mock(); + context.Setup(c => c.WorkContext).Returns(work.Object); + context.Setup(c => c.StoreContext).Returns(store.Object); + + //the real rule: an account without an owner is an owner + var groups = new Mock(); + groups.Setup(g => g.IsOwner(It.IsAny())) + .ReturnsAsync((Customer c) => string.IsNullOrEmpty(c.OwnerId)); + + _mediator = new Mock(); + _customerSettings = new CustomerSettings(); + _controller = new AccountController(new Mock().Object, + new Mock().Object, context.Object, new Mock().Object, + groups.Object, new Mock().Object, new Mock().Object, + _mediator.Object, new Mock().Object, new CaptchaSettings(), + _customerSettings, new CustomerConfig()); + _controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }; + _controller.TempData = new TempDataDictionary(_controller.HttpContext, new Mock().Object); + var url = new Mock(); + url.Setup(x => x.RouteUrl(It.IsAny())).Returns("/subaccounts"); + _controller.Url = url.Object; + } + + private static void AssertRedirectTo(IActionResult result, string routeName) + { + Assert.IsInstanceOfType(result, typeof(RedirectToRouteResult)); + Assert.AreEqual(routeName, ((RedirectToRouteResult)result).RouteName); + } + + private static object JsonValue(IActionResult result, string property) + { + var value = ((JsonResult)result).Value!; + return value.GetType().GetProperty(property)!.GetValue(value); + } + + //hiding the tab switches the feature off - the actions behind it used to stay reachable by URL + + [TestMethod] + public async Task SubAccountAddPost_TabHidden_CreatesNothing() + { + _customerSettings.HideSubAccountsTab = true; + + var result = await _controller.SubAccountAdd(new SubAccountCreateModel { Email = "a@b.com" }); + + AssertRedirectTo(result, "CustomerInfo"); + _mediator.Verify(m => m.Send(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task SubAccountEditPost_TabHidden_ChangesNothing() + { + _customerSettings.HideSubAccountsTab = true; + + var result = await _controller.SubAccountEdit(new SubAccountEditModel { Id = "sub" }); + + AssertRedirectTo(result, "CustomerInfo"); + _mediator.Verify(m => m.Send(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task SubAccountDelete_TabHidden_DeletesNothing() + { + _customerSettings.HideSubAccountsTab = true; + + var result = await _controller.SubAccountDelete("sub"); + + AssertRedirectTo(result, "CustomerInfo"); + _mediator.Verify(m => m.Send(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task SubAccountGetActions_TabHidden_RedirectToCustomerInfo() + { + _customerSettings.HideSubAccountsTab = true; + + AssertRedirectTo(await _controller.SubAccounts(), "CustomerInfo"); + AssertRedirectTo(await _controller.SubAccountAdd(), "CustomerInfo"); + AssertRedirectTo(await _controller.SubAccountEdit("sub"), "CustomerInfo"); + } + + [TestMethod] + public async Task SubAccountAddPost_CalledBySubAccount_IsChallenged() + { + _currentCustomer.OwnerId = "someone"; + + var result = await _controller.SubAccountAdd(new SubAccountCreateModel { Email = "a@b.com" }); + + Assert.IsInstanceOfType(result, typeof(ChallengeResult)); + _mediator.Verify(m => m.Send(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task SubAccountDelete_CalledBySubAccount_IsChallenged() + { + _currentCustomer.OwnerId = "someone"; + + var result = await _controller.SubAccountDelete("sub"); + + Assert.IsInstanceOfType(result, typeof(ChallengeResult)); + } + + [TestMethod] + public async Task SubAccountEditGet_SomeoneElsesAccount_RedirectsToList() + { + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync((SubAccountEditModel)null); + + var result = await _controller.SubAccountEdit("foreign"); + + AssertRedirectTo(result, "CustomerSubAccounts"); + } + + [TestMethod] + public async Task SubAccountEditGet_OwnAccount_ShowsIt() + { + var model = new SubAccountEditModel { Id = "sub" }; + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())).ReturnsAsync(model); + + var result = await _controller.SubAccountEdit("sub"); + + Assert.AreSame(model, ((ViewResult)result).Model); + } + + [TestMethod] + public async Task SubAccountDelete_Refused_ReturnsTheHandlersError() + { + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync((false, "not yours")); + + var result = await _controller.SubAccountDelete("foreign"); + + Assert.AreEqual(false, JsonValue(result, "success")); + Assert.AreEqual("not yours", JsonValue(result, "error")); + } + + [TestMethod] + public async Task SubAccountDelete_Done_ReportsSuccess() + { + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync((true, string.Empty)); + + var result = await _controller.SubAccountDelete("sub"); + + Assert.AreEqual(true, JsonValue(result, "success")); + Assert.AreEqual("/subaccounts", JsonValue(result, "redirect")); + } +} diff --git a/src/Tests/Grand.Web.Tests/Features/Customers/GetSubAccountHandlerTests.cs b/src/Tests/Grand.Web.Tests/Features/Customers/GetSubAccountHandlerTests.cs new file mode 100644 index 0000000000..658558cd9f --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Features/Customers/GetSubAccountHandlerTests.cs @@ -0,0 +1,58 @@ +using Grand.Business.Core.Interfaces.Customers; +using Grand.Domain.Customers; +using Grand.Web.Features.Handlers.Customers; +using Grand.Web.Features.Models.Customers; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Tests.Features.Customers; + +[TestClass] +public class GetSubAccountHandlerTests +{ + private Mock _customerServiceMock; + private GetSubAccountHandler _handler; + + [TestInitialize] + public void Init() + { + _customerServiceMock = new Mock(); + _handler = new GetSubAccountHandler(_customerServiceMock.Object); + } + + private static GetSubAccount Query(string id) => + new() { CurrentCustomer = new Customer { Id = "owner" }, CustomerId = id }; + + [TestMethod] + public async Task Handle_OwnSubAccount_ReturnsIt() + { + _customerServiceMock.Setup(c => c.GetCustomerById("sub")) + .ReturnsAsync(new Customer { Id = "sub", OwnerId = "owner", Email = "sub@x.com", Active = true }); + + var model = await _handler.Handle(Query("sub"), default); + + Assert.IsNotNull(model); + Assert.AreEqual("sub", model.Id); + Assert.AreEqual("sub@x.com", model.Email); + Assert.IsTrue(model.Active); + } + + [TestMethod] + [DataRow("another-owner", false)] + [DataRow("", false)] + [DataRow("owner", true)] + public async Task Handle_NotAnOwnLiveSubAccount_ReturnsNull(string ownerId, bool deleted) + { + //an empty model used to be returned, which rendered as a blank edit form + _customerServiceMock.Setup(c => c.GetCustomerById("x")) + .ReturnsAsync(new Customer { Id = "x", OwnerId = ownerId, Deleted = deleted, Email = "x@x.com" }); + + Assert.IsNull(await _handler.Handle(Query("x"), default)); + } + + [TestMethod] + public async Task Handle_MissingAccount_ReturnsNull() + { + Assert.IsNull(await _handler.Handle(Query("missing"), default)); + } +} diff --git a/src/Tests/Grand.Web.Tests/Validators/Customer/CustomerValidatorsStoreScopingTests.cs b/src/Tests/Grand.Web.Tests/Validators/Customer/CustomerValidatorsStoreScopingTests.cs index 2bdfc98ec0..bf2ec099db 100644 --- a/src/Tests/Grand.Web.Tests/Validators/Customer/CustomerValidatorsStoreScopingTests.cs +++ b/src/Tests/Grand.Web.Tests/Validators/Customer/CustomerValidatorsStoreScopingTests.cs @@ -38,7 +38,7 @@ private static Mock Translation() private static IContextAccessor Context(string currentCustomerEmail = "owner@x.com") { var work = new Mock(); - work.Setup(w => w.CurrentCustomer).Returns(new Customer { Email = currentCustomerEmail }); + work.Setup(w => w.CurrentCustomer).Returns(new Customer { Id = "owner", Email = currentCustomerEmail }); var store = new Mock(); store.Setup(s => s.CurrentStore).Returns(new Grand.Domain.Stores.Store { Id = StoreId }); var ctx = new Mock(); @@ -176,7 +176,7 @@ public async Task SubAccountEditValidator_ScopesEmailLookupToCurrentStore_WhenEm { var customerService = new Mock(); customerService.Setup(c => c.GetCustomerById("c1")) - .ReturnsAsync(new Customer { Id = "c1", Email = "old@x.com" }); + .ReturnsAsync(new Customer { Id = "c1", Email = "old@x.com", OwnerId = "owner" }); var groupService = new Mock(); groupService.Setup(g => g.IsRegistered(It.IsAny())).ReturnsAsync(true); diff --git a/src/Tests/Grand.Web.Tests/Validators/Customer/SubAccountEditValidatorTests.cs b/src/Tests/Grand.Web.Tests/Validators/Customer/SubAccountEditValidatorTests.cs new file mode 100644 index 0000000000..cb9f3d862d --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Validators/Customer/SubAccountEditValidatorTests.cs @@ -0,0 +1,121 @@ +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Domain.Customers; +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; +using Grand.Infrastructure.Validators; +using Grand.Web.Common.Localization; +using Grand.Web.Models.Customer; +using Grand.Web.Validators.Customer; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Tests.Validators.Customers; + +[TestClass] +public class SubAccountEditValidatorTests +{ + private Mock _customerService; + private Mock _groupService; + private SubAccountEditValidator _validator; + + [TestInitialize] + public void Init() + { + _customerService = new Mock(); + _groupService = new Mock(); + _groupService.Setup(g => g.IsRegistered(It.IsAny())).ReturnsAsync(true); + var translation = new Mock(); + translation.Setup(t => t.GetResource(It.IsAny())).Returns(k => k); + + var work = new Mock(); + work.Setup(w => w.CurrentCustomer).Returns(new Customer { Id = "owner" }); + var store = new Mock(); + store.Setup(s => s.CurrentStore).Returns(new Grand.Domain.Stores.Store { Id = "s1" }); + var context = new Mock(); + context.Setup(c => c.WorkContext).Returns(work.Object); + context.Setup(c => c.StoreContext).Returns(store.Object); + + _validator = new SubAccountEditValidator(new List>(), + _customerService.Object, _groupService.Object, translation.Object, context.Object, + new CustomerSettings { AllowUsersToChangeEmail = true }, new CustomerConfig()); + } + + private static SubAccountEditModel Model(string email = "new@x.com") => + new() { Id = "c1", Email = email, FirstName = "A", LastName = "B" }; + + [TestMethod] + public async Task SomeoneElsesAccount_GetsOnlyTheNotOwnerAnswer() + { + //an unregistered account of another owner: its state must not show through + var victim = new Customer { Id = "c1", OwnerId = "another-owner", Email = "victim@x.com" }; + _customerService.Setup(c => c.GetCustomerById("c1")).ReturnsAsync(victim); + _groupService.Setup(g => g.IsRegistered(victim)).ReturnsAsync(false); + + var result = await _validator.ValidateAsync(Model()); + + CollectionAssert.AreEqual(new[] { "Account.SubAccounts.NotOwner" }, + result.Errors.Select(e => e.ErrorMessage).ToArray()); + //and no probing of whether the new email is taken + _customerService.Verify(c => c.GetCustomerByEmail(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task MissingAccount_GetsTheSameAnswerAsSomeoneElses() + { + //otherwise the answer tells which customer ids exist + var result = await _validator.ValidateAsync(Model()); + + CollectionAssert.AreEqual(new[] { "Account.SubAccounts.NotOwner" }, + result.Errors.Select(e => e.ErrorMessage).ToArray()); + } + + [TestMethod] + public async Task NotRegisteredSubAccount_UsesTheResourceNotARawKey() + { + var sub = new Customer { Id = "c1", OwnerId = "owner", Email = "sub@x.com" }; + _customerService.Setup(c => c.GetCustomerById("c1")).ReturnsAsync(sub); + _groupService.Setup(g => g.IsRegistered(sub)).ReturnsAsync(false); + + var result = await _validator.ValidateAsync(Model()); + + Assert.IsTrue(result.Errors.Any(e => e.ErrorMessage == "Account.Login.WrongCredentials.NotRegistered")); + } + + [TestMethod] + public async Task EmptyEmail_FailsValidationWithoutThrowing() + { + _customerService.Setup(c => c.GetCustomerById("c1")) + .ReturnsAsync(new Customer { Id = "c1", OwnerId = "owner", Email = "sub@x.com" }); + + var result = await _validator.ValidateAsync(Model(email: null)); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Any(e => e.ErrorMessage == "Account.Fields.Email.Required")); + } + + [TestMethod] + public async Task SameEmailInOtherCase_IsNotAnEmailChange() + { + _customerService.Setup(c => c.GetCustomerById("c1")) + .ReturnsAsync(new Customer { Id = "c1", OwnerId = "owner", Email = "sub@x.com" }); + + var result = await _validator.ValidateAsync(Model(email: "Sub@X.com")); + + Assert.IsTrue(result.IsValid); + _customerService.Verify(c => c.GetCustomerByEmail(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task NewEmailTakenByAnotherCustomer_Fails() + { + _customerService.Setup(c => c.GetCustomerById("c1")) + .ReturnsAsync(new Customer { Id = "c1", OwnerId = "owner", Email = "sub@x.com" }); + _customerService.Setup(c => c.GetCustomerByEmail("new@x.com", It.IsAny())) + .ReturnsAsync(new Customer { Id = "other" }); + + var result = await _validator.ValidateAsync(Model()); + + Assert.IsTrue(result.Errors.Any(e => e.ErrorMessage == "Account.EmailUsernameErrors.EmailAlreadyExists")); + } +} diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index 9c81057e3ee07061b3d77f9b3edd891a08be68e1..69a851d0656681db760992a5b6150ea1e3798c86 100644 GIT binary patch delta 610 zcmY+=PiWIn90%~1Hc8)0(>808u5+$tV;gl$S+^b(hSUjq-C+#zWac(jaqYTRs_4N# zhwi96=+7ZY86v_Idh0=u2qF_N6+FxIq&rQJre4HfvU%9ylZ3qFeZTMbY{TAqYByiH z!8|GOO#gY9Cr9fGMN%M3#3DtqjMv+Eze(2lFSieN-pA&7?oS#n51Cm=O*8qS)2%sO0R>PHiAX{+ z3bmXrojVn@Ntp{B6QU3FI9NME8g?HW6!xZ=R=*Ub{I{Dh|L5gNg_QmDDk(teoqD2v zMH0i;u*Rxi%f>3bTx-sQ)z5M;Ho}yaHp*@Z{-bo7*SPJ$ z_Dkf3f4IZFeu{kYuK{;6$tW~NSt3#)D}L50u}Plaf2AMIgN2%O5uC{|PHb9>Lwlbp zaBf&p`18HvA&XS;w=#UbBNyuf*uksFx4w*fM;~WJT=oXM4kb=?!G>=``bk)R9lHwc z5jGPl;~KVu%)P)_c)L&IyrLSBqhooq(PP8z0Gk1AJ0#X8Y10d8t)jR*q@XZTQ3OR%3~8tb#gUE-WTHfiZuTa&{{aG^$@TyM delta 167 zcmZ29J8@n|;)WI9Czt&=(j4%sJ>VB35HkTWGZ3=?F)I+W0WtgbfL|Odbhp=Oa+WYo ze_+h9W%>j*PLpX*1h}UEImWqRdI1-w5|F1eUEwgZ+VlzU1WKkKP~>#j{=tZITV8w5 tB2FOY0%C3;<^f_}Am#&NejpYAVnHAl0%Bnx76D>WAQs!+vq)TQH2|zvOM3tS diff --git a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml index c3a246c9ab..5262947a25 100644 --- a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml +++ b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml @@ -198,4 +198,28 @@ The request failed. Try again. + + Inactive + + + This account is not one of your subaccounts. + + + This customer has subaccounts of its own, so it cannot become a subaccount. + + + This customer is itself a subaccount. Enter the e-mail address of the main account. + + + No customer with this e-mail address was found. + + + The owner must be a registered customer. + + + A customer cannot be its own owner. + + + Subaccounts + diff --git a/src/Web/Grand.Web/Commands/Handler/Customers/SubAccountDeleteCommandHandler.cs b/src/Web/Grand.Web/Commands/Handler/Customers/SubAccountDeleteCommandHandler.cs index 31026f5bfc..32d6ae227e 100644 --- a/src/Web/Grand.Web/Commands/Handler/Customers/SubAccountDeleteCommandHandler.cs +++ b/src/Web/Grand.Web/Commands/Handler/Customers/SubAccountDeleteCommandHandler.cs @@ -1,5 +1,6 @@ using Grand.Business.Core.Interfaces.Customers; using Grand.Web.Commands.Models.Customers; +using Grand.Web.Common.Localization; using Grand.Mediator; namespace Grand.Web.Commands.Handler.Customers; @@ -7,10 +8,13 @@ namespace Grand.Web.Commands.Handler.Customers; public class SubAccountDeleteCommandHandler : IRequestHandler { private readonly ICustomerService _customerService; + private readonly IRequestTranslationService _translationService; - public SubAccountDeleteCommandHandler(ICustomerService customerService) + public SubAccountDeleteCommandHandler(ICustomerService customerService, + IRequestTranslationService translationService) { _customerService = customerService; + _translationService = translationService; } public async Task<(bool success, string error)> Handle(SubAccountDeleteCommand request, @@ -19,8 +23,8 @@ public SubAccountDeleteCommandHandler(ICustomerService customerService) ArgumentNullException.ThrowIfNull(request.CurrentCustomer); var customer = await _customerService.GetCustomerById(request.CustomerId); - if (customer == null || customer.OwnerId != request.CurrentCustomer.Id) - return (false, "You are not owner of this account"); + if (customer == null || customer.Deleted || customer.OwnerId != request.CurrentCustomer.Id) + return (false, _translationService.GetResource("Account.SubAccounts.NotOwner")); //delete customer await _customerService.DeleteCustomer(customer); diff --git a/src/Web/Grand.Web/Commands/Handler/Customers/SubAccountEditCommandHandler.cs b/src/Web/Grand.Web/Commands/Handler/Customers/SubAccountEditCommandHandler.cs index 32555ba9c5..b0a2b721a9 100644 --- a/src/Web/Grand.Web/Commands/Handler/Customers/SubAccountEditCommandHandler.cs +++ b/src/Web/Grand.Web/Commands/Handler/Customers/SubAccountEditCommandHandler.cs @@ -26,12 +26,21 @@ public async Task Handle(SubAccountEditCommand request, CancellationToken { ArgumentNullException.ThrowIfNull(request.CurrentCustomer); var customer = await _customerService.GetCustomerById(request.EditModel.Id); + if (customer == null || customer.Deleted || customer.OwnerId != request.CurrentCustomer.Id) + return false; - //update email - if (customer.Email != request.EditModel.Email.ToLower() && _customerSettings.AllowUsersToChangeEmail) + //update email - stored lowercased like every other write, or the lowercased login lookup misses it + var email = request.EditModel.Email.Trim().ToLowerInvariant(); + if (customer.Email != email && _customerSettings.AllowUsersToChangeEmail) { - customer.Email = request.EditModel.Email; - await _customerService.UpdateCustomerField(customer, x => x.Email, request.EditModel.Email); + customer.Email = email; + await _customerService.UpdateCustomerField(customer, x => x.Email, email); + //without usernames the username is the email (see SubAccountAddCommandHandler) + if (!_customerSettings.UsernamesEnabled) + { + customer.Username = email; + await _customerService.UpdateCustomerField(customer, x => x.Username, email); + } } //update password (scope the lookup to the sub-account's store - safe with or without per-store identity) diff --git a/src/Web/Grand.Web/Controllers/AccountController.cs b/src/Web/Grand.Web/Controllers/AccountController.cs index 9061918f11..8c2755ebb0 100644 --- a/src/Web/Grand.Web/Controllers/AccountController.cs +++ b/src/Web/Grand.Web/Controllers/AccountController.cs @@ -1310,9 +1310,11 @@ await _customerService.UpdateUserField(_contextAccessor.WorkContext.Curr #region My account / Sub accounts - [HttpGet] - [CustomerGroupAuthorize(SystemCustomerGroupNames.Registered)] - public virtual async Task SubAccounts() + /// + /// Null when the current customer may manage sub-accounts, otherwise the result to return. Every + /// sub-account action goes through it, so hiding the tab switches the feature off and not only the link. + /// + private async Task SubAccountsDenied() { if (!await _groupService.IsOwner(_contextAccessor.WorkContext.CurrentCustomer)) return Challenge(); @@ -1320,6 +1322,16 @@ public virtual async Task SubAccounts() if (_customerSettings.HideSubAccountsTab) return RedirectToRoute("CustomerInfo"); + return null; + } + + [HttpGet] + [CustomerGroupAuthorize(SystemCustomerGroupNames.Registered)] + public virtual async Task SubAccounts() + { + var denied = await SubAccountsDenied(); + if (denied != null) return denied; + var model = await _mediator.Send(new GetSubAccounts { Customer = _contextAccessor.WorkContext.CurrentCustomer }); return View(model); @@ -1329,8 +1341,8 @@ public virtual async Task SubAccounts() [CustomerGroupAuthorize(SystemCustomerGroupNames.Registered)] public virtual async Task SubAccountAdd() { - if (!await _groupService.IsOwner(_contextAccessor.WorkContext.CurrentCustomer)) - return Challenge(); + var denied = await SubAccountsDenied(); + if (denied != null) return denied; var model = new SubAccountCreateModel { Active = true @@ -1342,8 +1354,8 @@ public virtual async Task SubAccountAdd() [CustomerGroupAuthorize(SystemCustomerGroupNames.Registered)] public virtual async Task SubAccountAdd(SubAccountCreateModel model) { - if (!await _groupService.IsOwner(_contextAccessor.WorkContext.CurrentCustomer)) - return Challenge(); + var denied = await SubAccountsDenied(); + if (denied != null) return denied; if (!ModelState.IsValid) return View(model); @@ -1360,11 +1372,13 @@ await _mediator.Send(new SubAccountAddCommand { [CustomerGroupAuthorize(SystemCustomerGroupNames.Registered)] public virtual async Task SubAccountEdit(string id) { - if (!await _groupService.IsOwner(_contextAccessor.WorkContext.CurrentCustomer)) - return Challenge(); + var denied = await SubAccountsDenied(); + if (denied != null) return denied; var model = await _mediator.Send(new GetSubAccount { CustomerId = id, CurrentCustomer = _contextAccessor.WorkContext.CurrentCustomer }); + if (model == null) + return RedirectToRoute("CustomerSubAccounts"); return View(model); } @@ -1373,11 +1387,12 @@ public virtual async Task SubAccountEdit(string id) [CustomerGroupAuthorize(SystemCustomerGroupNames.Registered)] public virtual async Task SubAccountEdit(SubAccountEditModel model) { - if (!await _groupService.IsOwner(_contextAccessor.WorkContext.CurrentCustomer)) - return Challenge(); + var denied = await SubAccountsDenied(); + if (denied != null) return denied; if (!ModelState.IsValid) return View(model); + //the handler re-checks the ownership the validator already checked and changes nothing when it fails _ = await _mediator.Send(new SubAccountEditCommand { CurrentCustomer = _contextAccessor.WorkContext.CurrentCustomer, EditModel = model, @@ -1391,26 +1406,18 @@ public virtual async Task SubAccountEdit(SubAccountEditModel mode [CustomerGroupAuthorize(SystemCustomerGroupNames.Registered)] public virtual async Task SubAccountDelete(string id) { - if (!await _groupService.IsOwner(_contextAccessor.WorkContext.CurrentCustomer)) - return Challenge(); + var denied = await SubAccountsDenied(); + if (denied != null) return denied; var result = await _mediator.Send(new SubAccountDeleteCommand { CurrentCustomer = _contextAccessor.WorkContext.CurrentCustomer, CustomerId = id }); - if (result.success) - return Json(new { - redirect = Url.RouteUrl("CustomerSubAccounts"), - success = true - }); - //errors return Json(new { redirect = Url.RouteUrl("CustomerSubAccounts"), - success = false, - error = string.Join("; ", ModelState.Values - .SelectMany(x => x.Errors) - .Select(x => x.ErrorMessage)) + success = result.success, + error = result.error }); } diff --git a/src/Web/Grand.Web/Features/Handlers/Customers/GetSubAccountHandler.cs b/src/Web/Grand.Web/Features/Handlers/Customers/GetSubAccountHandler.cs index 0be4202e6a..45e11bf93f 100644 --- a/src/Web/Grand.Web/Features/Handlers/Customers/GetSubAccountHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Customers/GetSubAccountHandler.cs @@ -20,18 +20,17 @@ public async Task Handle(GetSubAccount request, Cancellatio { ArgumentNullException.ThrowIfNull(request.CurrentCustomer); - var model = new SubAccountEditModel(); - + //null for a missing, deleted or someone else's account, so the caller can tell it from an empty form var subaccount = await _customerService.GetCustomerById(request.CustomerId); - if (subaccount != null && subaccount.OwnerId == request.CurrentCustomer.Id) - model = new SubAccountEditModel { - Id = subaccount.Id, - Email = subaccount.Email, - Active = subaccount.Active, - FirstName = subaccount.GetUserFieldFromEntity(SystemCustomerFieldNames.FirstName), - LastName = subaccount.GetUserFieldFromEntity(SystemCustomerFieldNames.LastName) - }; + if (subaccount == null || subaccount.Deleted || subaccount.OwnerId != request.CurrentCustomer.Id) + return null; - return model; + return new SubAccountEditModel { + Id = subaccount.Id, + Email = subaccount.Email, + Active = subaccount.Active, + FirstName = subaccount.GetUserFieldFromEntity(SystemCustomerFieldNames.FirstName), + LastName = subaccount.GetUserFieldFromEntity(SystemCustomerFieldNames.LastName) + }; } } \ No newline at end of file diff --git a/src/Web/Grand.Web/Validators/Customer/SubAccountEditValidator.cs b/src/Web/Grand.Web/Validators/Customer/SubAccountEditValidator.cs index 3e66f36527..ccc87d4757 100644 --- a/src/Web/Grand.Web/Validators/Customer/SubAccountEditValidator.cs +++ b/src/Web/Grand.Web/Validators/Customer/SubAccountEditValidator.cs @@ -43,27 +43,31 @@ public SubAccountEditValidator( { var customer = await customerService.GetCustomerById(x.Id); + //ownership first: a missing id and someone else's account get the same answer whatever its state, + //so the answer reveals neither which ids exist nor what state they are in + if (customer == null || customer.OwnerId != contextAccessor.WorkContext.CurrentCustomer.Id) + { + context.AddFailure(translationService.GetResource("Account.SubAccounts.NotOwner")); + return; + } + switch (customer) { - case null: - context.AddFailure( - translationService.GetResource("Account.NoExists")); - break; case { Deleted: true }: context.AddFailure( translationService.GetResource("Account.DeleteAccount")); - break; + return; case not null when !await groupService.IsRegistered(customer): context.AddFailure( - translationService.GetResource( - "Account is not registered")); - break; + translationService.GetResource("Account.Login.WrongCredentials.NotRegistered")); + return; } - if (customer != null && customer.OwnerId != contextAccessor.WorkContext.CurrentCustomer.Id) - context.AddFailure("You are not owner of this account"); + //an empty email is already reported by the NotEmpty rule + if (string.IsNullOrWhiteSpace(x.Email)) + return; - if (customer != null && customer.Email != x.Email.ToLower() && customerSettings.AllowUsersToChangeEmail) + if (customer.Email != x.Email.Trim().ToLowerInvariant() && customerSettings.AllowUsersToChangeEmail) { if (!CommonHelper.IsValidEmail(x.Email)) context.AddFailure( diff --git a/src/Web/Grand.Web/Views/Account/SubAccounts.cshtml b/src/Web/Grand.Web/Views/Account/SubAccounts.cshtml index 0a61af4bc6..59bbd3f0ef 100644 --- a/src/Web/Grand.Web/Views/Account/SubAccounts.cshtml +++ b/src/Web/Grand.Web/Views/Account/SubAccounts.cshtml @@ -31,6 +31,10 @@
@account.FirstName @account.LastName + @if (!account.Active) + { + @Loc["Account.SubAccounts.Inactive"] + }
From bf0c3b49071da2e029e46f4e021fc6db4c97b995 Mon Sep 17 00:00:00 2001 From: support Date: Sat, 24 Oct 2026 12:12:10 +0200 Subject: [PATCH 02/28] Validate sub-account owners in the admin and keep them on Store saves The Store panel blanked the owner on every customer save, which detached each sub-account a store manager touched. An existing customer now keeps the owner it has; a new one still gets none, and the posted value is never trusted. The admin owner field only checked that the e-mail existed. Sub-accounts stay one level deep now: the owner must be a registered, live customer that is not itself a sub-account, a customer with sub-accounts cannot get an owner, and a customer cannot own itself. The lookup on save uses the same store scope as the validator (it was global, so with per-store identity it could link another store's account), and an owner already linked is kept when posted back unchanged, so legacy data never blocks an unrelated save. The customer form lists an owner's sub-accounts and links a sub-account's owner, read only, on both panels and within each panel's data scope. --- .../Services/CustomerViewModelServiceTests.cs | 166 ++++++++++++- .../Validators/CustomerValidatorOwnerTests.cs | 221 ++++++++++++++++++ .../Controllers/CustomerControllerTests.cs | 29 +++ .../Models/Customers/CustomerModel.cs | 14 ++ .../Services/CustomerViewModelService.cs | 90 +++++-- .../Validators/Customers/CustomerValidator.cs | 67 ++++-- .../Partials/CreateOrUpdate.TabInfo.cshtml | 33 +++ .../Controllers/CustomerController.cs | 8 +- 8 files changed, 593 insertions(+), 35 deletions(-) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorOwnerTests.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs index 17ce621e1e..9c642de595 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs @@ -19,6 +19,8 @@ using Grand.Domain.Vendors; using Grand.Domain.Messages; using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; +using Grand.Web.AdminShared.Interfaces; using Grand.Web.AdminShared.Models.Customers; using Grand.Web.AdminShared.Services; using Grand.Web.Common.Localization; @@ -46,6 +48,8 @@ public class CustomerViewModelServiceTests private Mock _newsLetterSubscriptionServiceMock; private Mock _translationServiceMock; private Customer _currentCustomer; + private CustomerConfig _customerConfig; + private Mock> _scopeMock; private CustomerViewModelService _customerViewModelService; [TestInitialize] @@ -114,6 +118,13 @@ public void Setup() _groupServiceMock.Setup(g => g.GetAllByIds(It.IsAny())) .ReturnsAsync(new List()); + SetupGetAllCustomers(new PagedList()); + _customerConfig = new CustomerConfig(); + //Admin panel: no default store, every customer in reach + _scopeMock = new Mock>(); + _scopeMock.Setup(s => s.GetDefaultStoreIdAsync()).ReturnsAsync((string)null); + _scopeMock.Setup(s => s.HasAccess(It.IsAny())).ReturnsAsync(true); + _customerViewModelService = new CustomerViewModelService( _customerServiceMock.Object, _groupServiceMock.Object, @@ -133,7 +144,9 @@ public void Setup() new TaxSettings(), new LoyaltyPointsSettings(), new CommonSettings(), - _enumTranslationServiceMock.Object); + _enumTranslationServiceMock.Object, + _customerConfig, + _scopeMock.Object); } [TestMethod] @@ -432,6 +445,157 @@ public async Task PrepareCustomerList_NoCustomers_ReturnsEmpty() Assert.AreEqual(0, list.Count()); } + #region Owner and sub-accounts + + [TestMethod] + public async Task UpdateCustomerModel_OwnerEmailUnchanged_KeepsTheLinkedOwner() + { + //posted back as shown, the owner is kept even where an email lookup would find someone else + var owner = new Customer { Id = "owner-1", Email = "boss@firm.com" }; + _customerServiceMock.Setup(c => c.GetCustomerById("owner-1")).ReturnsAsync(owner); + _customerServiceMock.Setup(c => c.GetCustomerByEmail("boss@firm.com", It.IsAny())) + .ReturnsAsync(new Customer { Id = "same-email-other-store" }); + var customer = new Customer { Id = "c1", OwnerId = "owner-1" }; + + await _customerViewModelService.UpdateCustomerModel(customer, + new CustomerModel { Email = "c1@firm.com", Owner = "Boss@Firm.com " }); + + Assert.AreEqual("owner-1", customer.OwnerId); + } + + [TestMethod] + public async Task UpdateCustomerModel_NewOwner_PerStore_IsLookedUpInTheCustomersStore() + { + _customerConfig.RegisterCustomersPerStore = true; + _customerServiceMock.Setup(c => c.GetCustomerByEmail("boss@firm.com", "store-2")) + .ReturnsAsync(new Customer { Id = "owner-store-2" }); + var customer = new Customer { Id = "c1" }; + + await _customerViewModelService.UpdateCustomerModel(customer, + new CustomerModel { Email = "c1@firm.com", StoreId = "store-2", Owner = "boss@firm.com" }); + + //the same scope the validator checks in - the global lookup used to pick any store's account + Assert.AreEqual("owner-store-2", customer.OwnerId); + _customerServiceMock.Verify(c => c.GetCustomerByEmail("boss@firm.com", ""), Times.Never); + } + + [TestMethod] + public async Task UpdateCustomerModel_OwnerCleared_UnlinksTheOwner() + { + var customer = new Customer { Id = "c1", OwnerId = "owner-1" }; + + await _customerViewModelService.UpdateCustomerModel(customer, + new CustomerModel { Email = "c1@firm.com", Owner = "" }); + + Assert.AreEqual(string.Empty, customer.OwnerId); + } + + [TestMethod] + public async Task InsertCustomerModel_WithOwner_LinksIt() + { + _customerServiceMock.Setup(c => c.GetCustomerByEmail("boss@firm.com", "")) + .ReturnsAsync(new Customer { Id = "owner-1" }); + + var customer = await _customerViewModelService.InsertCustomerModel( + new CustomerModel { Email = "new@firm.com", Owner = "boss@firm.com" }); + + Assert.AreEqual("owner-1", customer.OwnerId); + } + + [TestMethod] + public async Task PrepareCustomerModel_Owner_ListsItsSubAccounts() + { + var owner = new Customer { Id = "owner-1", Email = "boss@firm.com" }; + _customerServiceMock + .Setup(c => c.GetAllCustomers(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), "owner-1", It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>>())) + .ReturnsAsync(new PagedList { + new() { Id = "sub-1", Email = "a@firm.com", Active = true, OwnerId = "owner-1" }, + new() { Id = "sub-2", Email = "b@firm.com", Active = false, OwnerId = "owner-1" } + }); + + var model = new CustomerModel(); + await _customerViewModelService.PrepareCustomerModel(model, owner, false); + + CollectionAssert.AreEqual(new[] { "sub-1", "sub-2" }, model.SubAccounts.Select(x => x.Id).ToArray()); + Assert.IsFalse(model.SubAccounts[1].Active); + Assert.IsTrue(string.IsNullOrEmpty(model.OwnerCustomerId)); + } + + [TestMethod] + public async Task PrepareCustomerModel_StorePanel_QueriesOnlyItsStoreAndSkipsOutOfScopeRows() + { + _scopeMock.Setup(s => s.GetDefaultStoreIdAsync()).ReturnsAsync("store-7"); + var foreign = new Customer { Id = "sub-x", Email = "x@firm.com", OwnerId = "owner-1" }; + _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false); + SetupGetAllCustomers(new PagedList { + new() { Id = "sub-1", Email = "a@firm.com", OwnerId = "owner-1" }, foreign + }); + + var model = new CustomerModel(); + await _customerViewModelService.PrepareCustomerModel(model, new Customer { Id = "owner-1" }, false); + + CollectionAssert.AreEqual(new[] { "sub-1" }, model.SubAccounts.Select(x => x.Id).ToArray()); + _customerServiceMock.Verify(c => c.GetAllCustomers(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), "store-7", "owner-1", It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>>()), Times.Once); + } + + [TestMethod] + public async Task PrepareCustomerModel_SalesManager_ListsOnlyTheirSubAccounts() + { + _currentCustomer.SeId = "se-1"; + + await _customerViewModelService.PrepareCustomerModel(new CustomerModel(), new Customer { Id = "owner-1" }, + false); + + _customerServiceMock.Verify(c => c.GetAllCustomers(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), "owner-1", "se-1", It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>>()), Times.Once); + } + + [TestMethod] + public async Task PrepareCustomerModel_SubAccount_LinksTheOwnerInReach() + { + var owner = new Customer { Id = "owner-1", Email = "boss@firm.com" }; + _customerServiceMock.Setup(c => c.GetCustomerById("owner-1")).ReturnsAsync(owner); + + var model = new CustomerModel(); + await _customerViewModelService.PrepareCustomerModel(model, + new Customer { Id = "sub-1", OwnerId = "owner-1" }, false); + + Assert.AreEqual("owner-1", model.OwnerCustomerId); + Assert.AreEqual("boss@firm.com", model.OwnerCustomerEmail); + Assert.AreEqual(0, model.SubAccounts.Count); + } + + [TestMethod] + public async Task PrepareCustomerModel_SubAccount_OwnerOutOfReach_IsNotShown() + { + var owner = new Customer { Id = "owner-1", Email = "boss@other-store.com" }; + _customerServiceMock.Setup(c => c.GetCustomerById("owner-1")).ReturnsAsync(owner); + _scopeMock.Setup(s => s.HasAccess(owner)).ReturnsAsync(false); + + var model = new CustomerModel(); + await _customerViewModelService.PrepareCustomerModel(model, + new Customer { Id = "sub-1", OwnerId = "owner-1" }, false); + + Assert.IsTrue(string.IsNullOrEmpty(model.OwnerCustomerId)); + Assert.IsTrue(string.IsNullOrEmpty(model.OwnerCustomerEmail)); + } + + #endregion + private void SetupGetAllCustomers(IPagedList result) { _customerServiceMock diff --git a/src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorOwnerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorOwnerTests.cs new file mode 100644 index 0000000000..2cef96d98f --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorOwnerTests.cs @@ -0,0 +1,221 @@ +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Domain; +using Grand.Domain.Customers; +using Grand.Domain.Orders; +using Grand.Domain.Stores; +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; +using Grand.Infrastructure.Validators; +using Grand.Web.AdminShared.Models.Customers; +using Grand.Web.AdminShared.Validators.Customers; +using Grand.Web.Common.Localization; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Web.Admin.Tests.Validators; + +/// +/// The owner field of the admin customer form: sub-accounts stay one level deep, and the owner already +/// linked never blocks an unrelated save. +/// +[TestClass] +public class CustomerValidatorOwnerTests +{ + private const string NotFound = "Admin.Customers.Customers.Fields.Owner.NotFound"; + private const string IsSubAccount = "Admin.Customers.Customers.Fields.Owner.IsSubAccount"; + private const string HasSubAccounts = "Admin.Customers.Customers.Fields.Owner.HasSubAccounts"; + private const string NotRegistered = "Admin.Customers.Customers.Fields.Owner.NotRegistered"; + private const string Self = "Admin.Customers.Customers.Fields.Owner.Self"; + + private Mock _customerService; + private Mock _groupService; + private CustomerConfig _customerConfig; + + [TestInitialize] + public void Setup() + { + _groupService = new Mock(); + _groupService + .Setup(g => g.GetAllCustomerGroups(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new PagedList()); + _groupService.Setup(g => g.IsAdmin(It.IsAny())).ReturnsAsync(true); + _groupService.Setup(g => g.IsRegistered(It.IsAny())).ReturnsAsync(true); + + //nobody has sub-accounts unless a test says so + _customerService = new Mock(); + _customerService + .Setup(c => c.GetAllCustomers(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>>())) + .ReturnsAsync(new PagedList()); + _customerConfig = new CustomerConfig(); + } + + private CustomerValidator Validator() + { + var translation = new Mock(); + translation.Setup(t => t.GetResource(It.IsAny())).Returns(k => k); + var work = new Mock(); + work.Setup(w => w.CurrentCustomer).Returns(new Customer { Id = "admin" }); + var store = new Mock(); + store.Setup(s => s.CurrentStore).Returns(new Store { Id = "s1" }); + var context = new Mock(); + context.Setup(c => c.WorkContext).Returns(work.Object); + context.Setup(c => c.StoreContext).Returns(store.Object); + + return new CustomerValidator(new List>(), translation.Object, + new Mock().Object, context.Object, _customerService.Object, _groupService.Object, + new CustomerSettings(), _customerConfig); + } + + private void SubAccountsOf(string ownerId, int count) + { + var list = new PagedList(); + for (var i = 0; i < count; i++) list.Add(new Customer { OwnerId = ownerId }); + _customerService + .Setup(c => c.GetAllCustomers(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), ownerId, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>>())) + .ReturnsAsync(list); + } + + private Customer Existing(string id = "c1", string ownerId = "") + { + var customer = new Customer { Id = id, Email = $"{id}@firm.com", OwnerId = ownerId }; + _customerService.Setup(c => c.GetCustomerById(id)).ReturnsAsync(customer); + return customer; + } + + private Customer OwnerAccount(string email = "boss@firm.com", string id = "owner-1", string ownerId = "") + { + var owner = new Customer { Id = id, Email = email, OwnerId = ownerId }; + _customerService.Setup(c => c.GetCustomerByEmail(email, It.IsAny())).ReturnsAsync(owner); + _customerService.Setup(c => c.GetCustomerById(id)).ReturnsAsync(owner); + return owner; + } + + private async Task OwnerErrors(CustomerModel model) => + (await Validator().ValidateAsync(model)).Errors + .Where(e => e.PropertyName == nameof(CustomerModel.Owner)).Select(e => e.ErrorMessage).ToArray(); + + private static CustomerModel Edit(string owner, string id = "c1") => + new() { Id = id, Email = $"{id}@firm.com", StoreId = "s1", Owner = owner }; + + private static CustomerModel Create(string owner) => + new() { Email = "new@firm.com", StoreId = "s1", Owner = owner }; + + [TestMethod] + public async Task ValidOwner_Passes() + { + Existing(); + OwnerAccount(); + + CollectionAssert.AreEqual(Array.Empty(), await OwnerErrors(Edit("boss@firm.com"))); + CollectionAssert.AreEqual(Array.Empty(), await OwnerErrors(Create("boss@firm.com"))); + } + + [TestMethod] + public async Task NoOwner_Passes() + { + Existing(); + + CollectionAssert.AreEqual(Array.Empty(), await OwnerErrors(Edit(""))); + } + + [TestMethod] + public async Task UnknownOwner_Fails() + { + Existing(); + + CollectionAssert.AreEqual(new[] { NotFound }, await OwnerErrors(Edit("nobody@firm.com"))); + CollectionAssert.AreEqual(new[] { NotFound }, await OwnerErrors(Create("nobody@firm.com"))); + } + + [TestMethod] + public async Task DeletedOwner_Fails() + { + Existing(); + OwnerAccount().Deleted = true; + + CollectionAssert.AreEqual(new[] { NotFound }, await OwnerErrors(Edit("boss@firm.com"))); + } + + [TestMethod] + public async Task OwnerThatIsASubAccount_Fails() + { + //a chain would let the top account reach orders two levels down + Existing(); + OwnerAccount(ownerId: "top-owner"); + + CollectionAssert.AreEqual(new[] { IsSubAccount }, await OwnerErrors(Edit("boss@firm.com"))); + } + + [TestMethod] + public async Task CustomerWithSubAccounts_CannotGetAnOwner() + { + Existing(); + OwnerAccount(); + SubAccountsOf("c1", 2); + + CollectionAssert.AreEqual(new[] { HasSubAccounts }, await OwnerErrors(Edit("boss@firm.com"))); + } + + [TestMethod] + public async Task UnregisteredOwner_Fails() + { + Existing(); + var owner = OwnerAccount(); + _groupService.Setup(g => g.IsRegistered(owner)).ReturnsAsync(false); + + CollectionAssert.AreEqual(new[] { NotRegistered }, await OwnerErrors(Edit("boss@firm.com"))); + } + + [TestMethod] + public async Task OwnEmail_Fails() + { + Existing(); + + CollectionAssert.AreEqual(new[] { Self }, await OwnerErrors(Edit("C1@Firm.com"))); + } + + [TestMethod] + public async Task OwnAccountUnderAnotherEmail_Fails() + { + //the email lookup resolves to the customer being edited (e.g. its email is being changed at the same time) + Existing(); + _customerService.Setup(c => c.GetCustomerByEmail("old@firm.com", It.IsAny())) + .ReturnsAsync(new Customer { Id = "c1" }); + + CollectionAssert.AreEqual(new[] { Self }, await OwnerErrors(Edit("old@firm.com"))); + } + + [TestMethod] + public async Task OwnerAlreadyLinked_NeverBlocksTheSave() + { + //legacy data the rules would now refuse (the owner became a sub-account) must not lock the form + Existing(ownerId: "owner-1"); + OwnerAccount(ownerId: "top-owner"); + + CollectionAssert.AreEqual(Array.Empty(), await OwnerErrors(Edit("Boss@Firm.com"))); + } + + [TestMethod] + public async Task PerStoreIdentity_LooksTheOwnerUpInTheCustomersStore() + { + _customerConfig.RegisterCustomersPerStore = true; + Existing(); + OwnerAccount(); + + await OwnerErrors(Edit("boss@firm.com")); + + _customerService.Verify(c => c.GetCustomerByEmail("boss@firm.com", "s1"), Times.Once); + } +} diff --git a/src/Tests/Grand.Web.Store.Tests/Controllers/CustomerControllerTests.cs b/src/Tests/Grand.Web.Store.Tests/Controllers/CustomerControllerTests.cs index 3b57d73699..ae9d36aafe 100644 --- a/src/Tests/Grand.Web.Store.Tests/Controllers/CustomerControllerTests.cs +++ b/src/Tests/Grand.Web.Store.Tests/Controllers/CustomerControllerTests.cs @@ -209,6 +209,35 @@ public async Task ApplyPostConstraints_CraftedPost_CannotSmuggleOwnershipFields( CollectionAssert.AreEqual(new[] { "registered-id" }, craftedModel.CustomerGroups.ToArray()); } + [TestMethod] + public async Task ApplyPostConstraints_EditedSubAccount_KeepsItsRealOwner() + { + //blanking the owner here detached every sub-account a store manager saved; a crafted value must not + //move it to another owner either + _customerServiceMock.Setup(c => c.GetCustomerById("sub-1")) + .ReturnsAsync(new Customer { Id = "sub-1", OwnerId = "owner-1" }); + _customerServiceMock.Setup(c => c.GetCustomerById("owner-1")) + .ReturnsAsync(new Customer { Id = "owner-1", Email = "boss@firm.com" }); + var controller = BuildStoreController(); + + var model = new CustomerModel { Id = "sub-1", Owner = "attacker@evil.com" }; + await controller.ApplyPostConstraintsPublic(model); + + Assert.AreEqual("boss@firm.com", model.Owner); + } + + [TestMethod] + public async Task ApplyPostConstraints_EditedCustomerWithoutOwner_GetsNone() + { + _customerServiceMock.Setup(c => c.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1" }); + var controller = BuildStoreController(); + + var model = new CustomerModel { Id = "c1", Owner = "attacker@evil.com" }; + await controller.ApplyPostConstraintsPublic(model); + + Assert.AreEqual("", model.Owner); + } + [TestMethod] public async Task ApplyPostConstraints_NoRegisteredGroupFound_ClearsCustomerGroups() { diff --git a/src/Web/Grand.Web.AdminShared/Models/Customers/CustomerModel.cs b/src/Web/Grand.Web.AdminShared/Models/Customers/CustomerModel.cs index 1c794ca16d..dc43991213 100644 --- a/src/Web/Grand.Web.AdminShared/Models/Customers/CustomerModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Customers/CustomerModel.cs @@ -218,6 +218,13 @@ public class CustomerModel : BaseEntityModel public IList AssociatedExternalAuthRecords { get; set; } = new List(); + //sub-accounts - display only, rebuilt from the entity on every render and never read back on save + public string OwnerCustomerId { get; set; } + + public string OwnerCustomerEmail { get; set; } + + public IList SubAccounts { get; set; } = new List(); + //customer notes [GrandResourceDisplayName("Admin.Customers.CustomerNotes.Fields.DisplayToCustomer")] public bool AddCustomerNoteDisplayToCustomer { get; set; } @@ -248,6 +255,13 @@ public class AssociatedExternalAuthModel : BaseEntityModel public string AuthMethodName { get; set; } } + public class SubAccountModel : BaseEntityModel + { + public string Email { get; set; } + public string FullName { get; set; } + public bool Active { get; set; } + } + public class LoyaltyPointsHistoryModel : BaseEntityModel { [GrandResourceDisplayName("Admin.Customers.Customers.LoyaltyPoints.Fields.Store")] diff --git a/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs index fcad7fe8d4..dbc79f91c1 100644 --- a/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs @@ -14,6 +14,7 @@ using Grand.Domain.Orders; using Grand.Domain.Tax; using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; using Grand.SharedKernel; using Grand.Web.AdminShared.Interfaces; using Grand.Web.AdminShared.Models.Customers; @@ -49,7 +50,9 @@ public class CustomerViewModelService : ICustomerViewModelService private readonly IRequestTranslationService _translationService; private readonly IContextAccessor _contextAccessor; private readonly IEnumTranslationService _enumTranslationService; - + private readonly CustomerConfig _customerConfig; + private readonly IAdminDataScope _scope; + public CustomerViewModelService( ICustomerService customerService, IGroupService groupService, @@ -68,9 +71,13 @@ public CustomerViewModelService( CustomerSettings customerSettings, TaxSettings taxSettings, LoyaltyPointsSettings loyaltyPointsSettings, - CommonSettings commonSettings, - IEnumTranslationService enumTranslationService) + CommonSettings commonSettings, + IEnumTranslationService enumTranslationService, + CustomerConfig customerConfig, + IAdminDataScope scope) { + _customerConfig = customerConfig; + _scope = scope; _customerService = customerService; _groupService = groupService; _newsLetterSubscriptionService = newsLetterSubscriptionService; @@ -146,6 +153,7 @@ public virtual async Task PrepareCustomerModel(CustomerModel model, Customer cus { model.Id = customer.Id; model.ShowMessageContactForm = _commonSettings.StoreInDatabaseContactUsForm; + await PrepareSubAccounts(model, customer); if (!excludeProperties) { model.Email = customer.Email; @@ -332,15 +340,71 @@ public virtual async Task PrepareCustomerModel(CustomerModel model, Customer cus !customer.Active; } - public virtual async Task InsertCustomerModel(CustomerModel model) + /// + /// The owner id the posted owner email stands for. The owner already linked is kept when its email is + /// posted back unchanged; otherwise the email is looked up in the same store scope the validator used. + /// + private async Task ResolveOwnerId(Customer customer, CustomerModel model) + { + if (string.IsNullOrWhiteSpace(model.Owner)) + return string.Empty; + + var ownerEmail = model.Owner.Trim(); + if (!string.IsNullOrEmpty(customer?.OwnerId)) + { + var currentOwner = await _customerService.GetCustomerById(customer.OwnerId); + if (currentOwner != null && + string.Equals(currentOwner.Email, ownerEmail, StringComparison.OrdinalIgnoreCase)) + return customer.OwnerId; + } + + var storeId = _customerConfig.RegisterCustomersPerStore ? model.StoreId : ""; + var owner = await _customerService.GetCustomerByEmail(ownerEmail, storeId); + return owner?.Id ?? customer?.OwnerId ?? string.Empty; + } + + private async Task PrepareSubAccounts(CustomerModel model, Customer customer) { - var ownerId = string.Empty; - if (!string.IsNullOrEmpty(model.Owner)) + model.OwnerCustomerId = string.Empty; + model.OwnerCustomerEmail = string.Empty; + model.SubAccounts.Clear(); + //sub-accounts are one level deep, so a sub-account never has its own - only an owner, linked when the + //panel may open it + if (!string.IsNullOrEmpty(customer.OwnerId)) { - var customerOwner = await _customerService.GetCustomerByEmail(model.Owner); - if (customerOwner != null) ownerId = customerOwner.Id; + var owner = await _customerService.GetCustomerById(customer.OwnerId); + if (owner != null && await _scope.HasAccess(owner)) + { + model.OwnerCustomerId = owner.Id; + model.OwnerCustomerEmail = owner.Email; + } + + return; } + //scoped in the query like the customer list (store of the Store panel, sales employee of a sales + //manager); the panel's own access check then guards each row the same way it guards opening it + var storeId = await _scope.GetDefaultStoreIdAsync() ?? ""; + var subAccounts = await _customerService.GetAllCustomers(ownerId: customer.Id, storeId: storeId, + salesEmployeeId: _contextAccessor.WorkContext.CurrentCustomer.SeId); + foreach (var item in subAccounts) + { + if (!await _scope.HasAccess(item)) + continue; + + model.SubAccounts.Add(new CustomerModel.SubAccountModel { + Id = item.Id, + Email = item.Email, + FullName = item.GetFullName(), + Active = item.Active + }); + } + } + + public virtual async Task InsertCustomerModel(CustomerModel model) + { + var ownerId = await ResolveOwnerId(null, model); + var customer = new Customer { CustomerGuid = Guid.NewGuid(), Email = model.Email, @@ -470,15 +534,7 @@ await _customerService.UpdateUserField(customer, SystemCustomerFieldNames.TwoFac _ => customer.Username }; - if (!string.IsNullOrEmpty(model.Owner)) - { - var customerOwner = await _customerService.GetCustomerByEmail(model.Owner); - if (customerOwner != null) customer.OwnerId = customerOwner.Id; - } - else - { - customer.OwnerId = string.Empty; - } + customer.OwnerId = await ResolveOwnerId(customer, model); //VAT number if (_taxSettings.EuVatEnabled) diff --git a/src/Web/Grand.Web.AdminShared/Validators/Customers/CustomerValidator.cs b/src/Web/Grand.Web.AdminShared/Validators/Customers/CustomerValidator.cs index e56cfb508f..4aa06140c1 100644 --- a/src/Web/Grand.Web.AdminShared/Validators/Customers/CustomerValidator.cs +++ b/src/Web/Grand.Web.AdminShared/Validators/Customers/CustomerValidator.cs @@ -173,6 +173,54 @@ async Task ValidateCustomerGroups(IList customerGroups, C return ""; } + //sub-accounts are one level deep: an owner is never a sub-account itself and a customer with + //sub-accounts never becomes one, so the owner's access to their orders cannot chain further + async Task ValidateOwner(CustomerModel x, Customer customer, ValidationContext context) + { + if (string.IsNullOrWhiteSpace(x.Owner)) + return; + + var ownerEmail = x.Owner.Trim(); + + //the owner already linked stays valid, so an unrelated edit of an existing account is never blocked + if (!string.IsNullOrEmpty(customer?.OwnerId)) + { + var currentOwner = await customerService.GetCustomerById(customer.OwnerId); + if (currentOwner != null && + string.Equals(currentOwner.Email, ownerEmail, StringComparison.OrdinalIgnoreCase)) + return; + } + + if (string.Equals(ownerEmail, x.Email?.Trim(), StringComparison.OrdinalIgnoreCase)) + { + context.AddFailure(nameof(x.Owner), + translationService.GetResource("Admin.Customers.Customers.Fields.Owner.Self")); + return; + } + + var owner = await customerService.GetCustomerByEmail(ownerEmail, StoreScope(x)); + if (owner == null || owner.Deleted) + { + context.AddFailure(nameof(x.Owner), + translationService.GetResource("Admin.Customers.Customers.Fields.Owner.NotFound")); + return; + } + + if (owner.Id == customer?.Id) + context.AddFailure(nameof(x.Owner), + translationService.GetResource("Admin.Customers.Customers.Fields.Owner.Self")); + else if (!string.IsNullOrEmpty(owner.OwnerId)) + context.AddFailure(nameof(x.Owner), + translationService.GetResource("Admin.Customers.Customers.Fields.Owner.IsSubAccount")); + else if (!await groupService.IsRegistered(owner)) + context.AddFailure(nameof(x.Owner), + translationService.GetResource("Admin.Customers.Customers.Fields.Owner.NotRegistered")); + else if (customer != null && + (await customerService.GetAllCustomers(ownerId: customer.Id, pageSize: 1)).Count > 0) + context.AddFailure(nameof(x.Owner), + translationService.GetResource("Admin.Customers.Customers.Fields.Owner.HasSubAccounts")); + } + void CustomerCreateValidator() { When(x => string.IsNullOrEmpty(x.Id), () => @@ -194,12 +242,7 @@ void CustomerCreateValidator() context.AddFailure("Email is already registered"); } - if (!string.IsNullOrWhiteSpace(x.Owner)) - { - var customerOwner = await customerService.GetCustomerByEmail(x.Owner, StoreScope(x)); - if (customerOwner == null) - context.AddFailure("Owner email is not exists"); - } + await ValidateOwner(x, null, context); if (!string.IsNullOrWhiteSpace(x.Username) && customerSettings.UsernamesEnabled) { @@ -235,17 +278,9 @@ void CustomerEditValidator() var customerGroupsError = await ValidateCustomerGroups(newCustomerGroups, x); if (!string.IsNullOrEmpty(customerGroupsError)) context.AddFailure(customerGroupsError); - if (!string.IsNullOrWhiteSpace(x.Owner)) - { - var customerByOwner = await customerService.GetCustomerByEmail(x.Owner, StoreScope(x)); - if (customerByOwner == null) - context.AddFailure("Owner email is not exists"); - - if (string.Equals(x.Owner, x.Email, StringComparison.CurrentCultureIgnoreCase)) - context.AddFailure("You can't assign own email"); - } - var customer = await customerService.GetCustomerById(x.Id); + await ValidateOwner(x, customer, context); + if (await groupService.IsSalesManager(contextAccessor.WorkContext.CurrentCustomer) && customer?.Id == contextAccessor.WorkContext.CurrentCustomer.Id) context.AddFailure("You can't edit own data from admin panel"); diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Customer/Partials/CreateOrUpdate.TabInfo.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Customer/Partials/CreateOrUpdate.TabInfo.cshtml index 513a1d317b..b1b8aa7948 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Customer/Partials/CreateOrUpdate.TabInfo.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Customer/Partials/CreateOrUpdate.TabInfo.cshtml @@ -105,6 +105,39 @@
} +@* read only on both panels: an owner lists its sub-accounts, a sub-account links its owner *@ +@if (Model.SubAccounts.Count > 0) +{ + + + + + + + + + + + @foreach (var subAccount in Model.SubAccounts) + { + + + + + + } + +
@Loc["Admin.Customers.Customers.Fields.Email"]@Loc["Admin.Customers.Customers.Fields.FullName"]@Loc["Admin.Customers.Customers.Fields.Active"]
@subAccount.Email@subAccount.FullName@(subAccount.Active ? Loc["Admin.Common.Yes"] : Loc["Admin.Common.No"])
+
+} +else if (!string.IsNullOrEmpty(Model.OwnerCustomerId)) +{ + + + +}
@if (Model.GenderEnabled) diff --git a/src/Web/Grand.Web.Store/Controllers/CustomerController.cs b/src/Web/Grand.Web.Store/Controllers/CustomerController.cs index 730314c433..368e325a23 100644 --- a/src/Web/Grand.Web.Store/Controllers/CustomerController.cs +++ b/src/Web/Grand.Web.Store/Controllers/CustomerController.cs @@ -50,7 +50,13 @@ public class CustomerController( protected override async Task ApplyPostConstraints(CustomerModel model) { model.StoreId = Scope.DefaultStoreId; - model.Owner = ""; + //the owner link is not the store manager's to change: a new customer gets none and an existing one + //keeps its own (blanking it here used to detach every sub-account the store manager saved) + var existing = string.IsNullOrEmpty(model.Id) ? null : await CustomerService.GetCustomerById(model.Id); + var owner = string.IsNullOrEmpty(existing?.OwnerId) + ? null + : await CustomerService.GetCustomerById(existing.OwnerId); + model.Owner = owner?.Email ?? ""; model.VendorId = ""; model.StaffStoreId = ""; model.SeId = ""; From 009675c92d5986a8b349833cb8c906c800ad254c Mon Sep 17 00:00:00 2001 From: support Date: Sat, 24 Oct 2026 12:12:11 +0200 Subject: [PATCH 03/28] Index customers by owner Sub-accounts are listed on the storefront and the admin customer form, and the admin checks for them before linking an owner. Without an index each of these scanned the whole customer collection. Fresh installs create it with the other customer indexes, existing ones through a 2.5 migration. --- .../Services/InstallationService.cs | 3 ++ .../2.5/MigrationCustomerOwnerIndex.cs | 39 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationCustomerOwnerIndex.cs diff --git a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs index a39d505e4d..3a45b80638 100644 --- a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs +++ b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs @@ -492,6 +492,9 @@ await dbContext.CreateIndex(_customerRepository, OrderBuilder.Create().Ascending(x => x.Email).Ascending(x => x.StoreId), "Email_StoreId"); await dbContext.CreateIndex(_customerRepository, OrderBuilder.Create().Ascending(x => x.Username).Ascending(x => x.StoreId), "Username_StoreId"); + //an owner's sub-accounts + await dbContext.CreateIndex(_customerRepository, + OrderBuilder.Create().Ascending(x => x.OwnerId), "OwnerId_1"); await dbContext.CreateIndex(_customerRepository, OrderBuilder.Create().Descending(x => x.CreatedOnUtc), "CreatedOnUtc"); diff --git a/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationCustomerOwnerIndex.cs b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationCustomerOwnerIndex.cs new file mode 100644 index 0000000000..e78f7f75a1 --- /dev/null +++ b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationCustomerOwnerIndex.cs @@ -0,0 +1,39 @@ +using Grand.Data; +using Grand.Domain.Customers; +using Grand.Infrastructure.Migrations; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Grand.Module.Migration.Migrations._2._5; + +public class MigrationCustomerOwnerIndex : IMigration +{ + private const string IndexName = "OwnerId_1"; + + public int Priority => 1; + public DbVersion Version => new(2, 5); + public Guid Identity => new("6f0d4b9e-3c1a-4e27-9a85-2b7e4c1d9f63"); + public string Name => "Index on Customer owner 2.5"; + + public async Task UpgradeProcess(IServiceProvider serviceProvider) + { + var repository = serviceProvider.GetRequiredService>(); + var databaseContext = serviceProvider.GetRequiredService(); + var logService = serviceProvider.GetRequiredService>(); + + try + { + //an owner's sub-accounts are listed on the storefront and in the admin customer form, and the admin + //checks for them before linking an owner - each a scan of the whole customer collection without it + await databaseContext.CreateIndex(repository, + OrderBuilder.Create().Ascending(x => x.OwnerId), IndexName); + } + catch (Exception ex) + { + logService.LogError(ex, "UpgradeProcess - CustomerOwnerIndex (2.5)"); + return false; + } + + return true; + } +} From 31ff5029d114807fccf5e7e8cec2464c86b612d7 Mon Sep 17 00:00:00 2001 From: support Date: Sat, 24 Oct 2026 19:51:31 +0200 Subject: [PATCH 04/28] Close the review findings on sub-account owners - The Store panel replaces the posted owner before saving, so its validator no longer checks the posted value: the answers told a store manager whether another store's e-mail existed, was a sub-account, was registered or had sub-accounts. - An unchanged owner is kept only while the customer stays in its store; a customer moved to another store has its owner looked up there. - A sub-account e-mail differing only in case is repaired on save even when e-mail changes are not allowed. Older edits stored it as typed, and those rows still could not sign in and failed on a password change. - An owner's own deleted sub-account gets the same answer as a missing or foreign one, as the edit page and the command already give. --- .../Services/CustomerViewModelServiceTests.cs | 16 +++++++++ .../Validators/CustomerValidatorOwnerTests.cs | 35 ++++++++++++++++++- .../Validators/CustomerValidatorTests.cs | 4 ++- .../SubAccountEditCommandHandlerTests.cs | 15 ++++++++ .../Customer/SubAccountEditValidatorTests.cs | 13 +++++++ .../Services/CustomerViewModelService.cs | 3 +- .../Validators/Customers/CustomerValidator.cs | 15 ++++++-- .../Customers/SubAccountEditCommandHandler.cs | 7 ++-- .../Customer/SubAccountEditValidator.cs | 11 +++--- 9 files changed, 104 insertions(+), 15 deletions(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs index 9c642de595..363f4f39fd 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs @@ -479,6 +479,22 @@ await _customerViewModelService.UpdateCustomerModel(customer, _customerServiceMock.Verify(c => c.GetCustomerByEmail("boss@firm.com", ""), Times.Never); } + [TestMethod] + public async Task UpdateCustomerModel_PerStore_CustomerMovedToAnotherStore_LooksTheOwnerUpAgain() + { + _customerConfig.RegisterCustomersPerStore = true; + _customerServiceMock.Setup(c => c.GetCustomerById("owner-a")) + .ReturnsAsync(new Customer { Id = "owner-a", Email = "boss@firm.com" }); + _customerServiceMock.Setup(c => c.GetCustomerByEmail("boss@firm.com", "store-b")) + .ReturnsAsync(new Customer { Id = "owner-b" }); + var customer = new Customer { Id = "c1", OwnerId = "owner-a", StoreId = "store-a" }; + + await _customerViewModelService.UpdateCustomerModel(customer, + new CustomerModel { Email = "c1@firm.com", StoreId = "store-b", Owner = "boss@firm.com" }); + + Assert.AreEqual("owner-b", customer.OwnerId); + } + [TestMethod] public async Task UpdateCustomerModel_OwnerCleared_UnlinksTheOwner() { diff --git a/src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorOwnerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorOwnerTests.cs index 2cef96d98f..3c8aa39112 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorOwnerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorOwnerTests.cs @@ -7,6 +7,7 @@ using Grand.Infrastructure; using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Validators; +using Grand.Web.AdminShared.Interfaces; using Grand.Web.AdminShared.Models.Customers; using Grand.Web.AdminShared.Validators.Customers; using Grand.Web.Common.Localization; @@ -32,10 +33,15 @@ public class CustomerValidatorOwnerTests private Mock _customerService; private Mock _groupService; private CustomerConfig _customerConfig; + private Mock> _scope; [TestInitialize] public void Setup() { + //Admin panel: no default store, the owner field is editable + _scope = new Mock>(); + _scope.Setup(s => s.GetDefaultStoreIdAsync()).ReturnsAsync((string)null); + _groupService = new Mock(); _groupService .Setup(g => g.GetAllCustomerGroups(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) @@ -70,7 +76,7 @@ private CustomerValidator Validator() return new CustomerValidator(new List>(), translation.Object, new Mock().Object, context.Object, _customerService.Object, _groupService.Object, - new CustomerSettings(), _customerConfig); + new CustomerSettings(), _customerConfig, _scope.Object); } private void SubAccountsOf(string ownerId, int count) @@ -207,6 +213,33 @@ public async Task OwnerAlreadyLinked_NeverBlocksTheSave() CollectionAssert.AreEqual(Array.Empty(), await OwnerErrors(Edit("Boss@Firm.com"))); } + [TestMethod] + public async Task StorePanel_DoesNotCheckThePostedOwner() + { + //the Store panel replaces the posted owner before saving; checking it would answer a store manager + //whether another store's e-mail exists, is a sub-account, is registered or has sub-accounts + _scope.Setup(s => s.GetDefaultStoreIdAsync()).ReturnsAsync("s1"); + Existing(); + OwnerAccount(ownerId: "top-owner"); + + CollectionAssert.AreEqual(Array.Empty(), await OwnerErrors(Edit("boss@firm.com"))); + _customerService.Verify(c => c.GetCustomerByEmail("boss@firm.com", It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task OwnerAlreadyLinked_CustomerMovedToAnotherStore_MustHoldInTheNewStore() + { + _customerConfig.RegisterCustomersPerStore = true; + var customer = Existing(ownerId: "owner-1"); + customer.StoreId = "store-a"; + var owner = new Customer { Id = "owner-1", Email = "boss@firm.com" }; + _customerService.Setup(c => c.GetCustomerById("owner-1")).ReturnsAsync(owner); + _customerService.Setup(c => c.GetCustomerByEmail("boss@firm.com", "store-a")).ReturnsAsync(owner); + + //Edit() posts StoreId "s1": the owner lives in store-a only, so the link would cross stores + CollectionAssert.AreEqual(new[] { NotFound }, await OwnerErrors(Edit("boss@firm.com"))); + } + [TestMethod] public async Task PerStoreIdentity_LooksTheOwnerUpInTheCustomersStore() { diff --git a/src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorTests.cs b/src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorTests.cs index c3666870fb..818d1bdf00 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorTests.cs @@ -6,6 +6,7 @@ using Grand.Infrastructure; using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Validators; +using Grand.Web.AdminShared.Interfaces; using Grand.Web.AdminShared.Models.Customers; using Grand.Web.AdminShared.Validators.Customers; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -52,7 +53,8 @@ public void Setup() new Mock().Object, _groupServiceMock.Object, new CustomerSettings(), - new CustomerConfig()); + new CustomerConfig(), + new Mock>().Object); } private static CustomerModel BuildModel(string storeId) => diff --git a/src/Tests/Grand.Web.Tests/Commands/Customers/SubAccountEditCommandHandlerTests.cs b/src/Tests/Grand.Web.Tests/Commands/Customers/SubAccountEditCommandHandlerTests.cs index a27f7fea6d..aa09ae098c 100644 --- a/src/Tests/Grand.Web.Tests/Commands/Customers/SubAccountEditCommandHandlerTests.cs +++ b/src/Tests/Grand.Web.Tests/Commands/Customers/SubAccountEditCommandHandlerTests.cs @@ -169,6 +169,21 @@ public async Task Handle_NewEmail_UsernamesEnabled_LeavesUsername() Assert.AreEqual("sub@x.com", customer.Username); } + [TestMethod] + public async Task Handle_EmailStoredInMixedCaseByOlderEdits_IsRepairedEvenWhenChangesAreNotAllowed() + { + //such a row cannot sign in, and its password change looked it up by that e-mail and threw + _customerSettings.AllowUsersToChangeEmail = false; + var customer = SubAccount(); + customer.Email = "Sub@X.com"; + + await _handler.Handle(Command(email: "Sub@X.com", password: "newpass"), default); + + Assert.AreEqual("sub@x.com", customer.Email); + _customerManagerServiceMock.Verify(m => m.ChangePassword( + It.Is(r => r.Email == "sub@x.com"), "store-9"), Times.Once); + } + [TestMethod] public async Task Handle_EmailChangeNotAllowed_KeepsEmail() { diff --git a/src/Tests/Grand.Web.Tests/Validators/Customer/SubAccountEditValidatorTests.cs b/src/Tests/Grand.Web.Tests/Validators/Customer/SubAccountEditValidatorTests.cs index cb9f3d862d..bae32c9672 100644 --- a/src/Tests/Grand.Web.Tests/Validators/Customer/SubAccountEditValidatorTests.cs +++ b/src/Tests/Grand.Web.Tests/Validators/Customer/SubAccountEditValidatorTests.cs @@ -70,6 +70,19 @@ public async Task MissingAccount_GetsTheSameAnswerAsSomeoneElses() result.Errors.Select(e => e.ErrorMessage).ToArray()); } + [TestMethod] + public async Task OwnDeletedSubAccount_GetsTheSameAnswer() + { + //the edit page and the command treat it as not yours; "Delete account" was a button label, not an error + _customerService.Setup(c => c.GetCustomerById("c1")) + .ReturnsAsync(new Customer { Id = "c1", OwnerId = "owner", Deleted = true, Email = "d@x.com" }); + + var result = await _validator.ValidateAsync(Model()); + + CollectionAssert.AreEqual(new[] { "Account.SubAccounts.NotOwner" }, + result.Errors.Select(e => e.ErrorMessage).ToArray()); + } + [TestMethod] public async Task NotRegisteredSubAccount_UsesTheResourceNotARawKey() { diff --git a/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs index dbc79f91c1..e4fd2f596b 100644 --- a/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs @@ -350,7 +350,8 @@ private async Task ResolveOwnerId(Customer customer, CustomerModel model return string.Empty; var ownerEmail = model.Owner.Trim(); - if (!string.IsNullOrEmpty(customer?.OwnerId)) + if (!string.IsNullOrEmpty(customer?.OwnerId) && + (!_customerConfig.RegisterCustomersPerStore || customer.StoreId == model.StoreId)) { var currentOwner = await _customerService.GetCustomerById(customer.OwnerId); if (currentOwner != null && diff --git a/src/Web/Grand.Web.AdminShared/Validators/Customers/CustomerValidator.cs b/src/Web/Grand.Web.AdminShared/Validators/Customers/CustomerValidator.cs index 4aa06140c1..5b5a0435de 100644 --- a/src/Web/Grand.Web.AdminShared/Validators/Customers/CustomerValidator.cs +++ b/src/Web/Grand.Web.AdminShared/Validators/Customers/CustomerValidator.cs @@ -6,6 +6,7 @@ using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Validators; using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Interfaces; using Grand.Web.AdminShared.Models.Customers; using System.Text.RegularExpressions; using Grand.Web.Common.Localization; @@ -22,7 +23,8 @@ public CustomerValidator( ICustomerService customerService, IGroupService groupService, CustomerSettings customerSettings, - CustomerConfig customerConfig) + CustomerConfig customerConfig, + IAdminDataScope scope) : base(validators) { //when per-store customer identity is enabled, uniqueness is scoped to the customer's store @@ -180,10 +182,17 @@ async Task ValidateOwner(CustomerModel x, Customer customer, ValidationContext Handle(SubAccountEditCommand request, CancellationToken if (customer == null || customer.Deleted || customer.OwnerId != request.CurrentCustomer.Id) return false; - //update email - stored lowercased like every other write, or the lowercased login lookup misses it + //update email - stored lowercased like every other write, or the lowercased login lookup misses it. An + //address differing only in case is the same address, so it is repaired even when changing it is not + //allowed: older edits stored it as typed, which locked the account out and broke its password change var email = request.EditModel.Email.Trim().ToLowerInvariant(); - if (customer.Email != email && _customerSettings.AllowUsersToChangeEmail) + var sameAddress = string.Equals(customer.Email, email, StringComparison.OrdinalIgnoreCase); + if (customer.Email != email && (sameAddress || _customerSettings.AllowUsersToChangeEmail)) { customer.Email = email; await _customerService.UpdateCustomerField(customer, x => x.Email, email); diff --git a/src/Web/Grand.Web/Validators/Customer/SubAccountEditValidator.cs b/src/Web/Grand.Web/Validators/Customer/SubAccountEditValidator.cs index ccc87d4757..6cce1b35db 100644 --- a/src/Web/Grand.Web/Validators/Customer/SubAccountEditValidator.cs +++ b/src/Web/Grand.Web/Validators/Customer/SubAccountEditValidator.cs @@ -43,9 +43,10 @@ public SubAccountEditValidator( { var customer = await customerService.GetCustomerById(x.Id); - //ownership first: a missing id and someone else's account get the same answer whatever its state, - //so the answer reveals neither which ids exist nor what state they are in - if (customer == null || customer.OwnerId != contextAccessor.WorkContext.CurrentCustomer.Id) + //ownership first: a missing id, a deleted account and someone else's account get the same answer + //(the edit page and the command treat them alike), so it reveals neither ids nor their state + if (customer == null || customer.Deleted || + customer.OwnerId != contextAccessor.WorkContext.CurrentCustomer.Id) { context.AddFailure(translationService.GetResource("Account.SubAccounts.NotOwner")); return; @@ -53,10 +54,6 @@ public SubAccountEditValidator( switch (customer) { - case { Deleted: true }: - context.AddFailure( - translationService.GetResource("Account.DeleteAccount")); - return; case not null when !await groupService.IsRegistered(customer): context.AddFailure( translationService.GetResource("Account.Login.WrongCredentials.NotRegistered")); From 7fb9a9d84fb0ec7343c31fbb48634a93cd8cfaed Mon Sep 17 00:00:00 2001 From: support Date: Sat, 24 Oct 2026 20:28:17 +0200 Subject: [PATCH 05/28] Keep a customer's sales employee, vendor, staff store and groups on Store saves The Store panel blanked the sales employee, vendor and staff store and reset the groups to Registered on every customer save. A store manager fixing a typo in a name took away the customer's sales employee and price groups, and a colleague's Store manager role. An existing customer now keeps all of these as stored; a new one still gets none of them and Registered only. Posted values are never used either way. --- .../Controllers/CustomerControllerTests.cs | 25 ++++++++++++++++++ .../Controllers/CustomerController.cs | 26 ++++++++++++++----- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/Tests/Grand.Web.Store.Tests/Controllers/CustomerControllerTests.cs b/src/Tests/Grand.Web.Store.Tests/Controllers/CustomerControllerTests.cs index ae9d36aafe..aff78fec18 100644 --- a/src/Tests/Grand.Web.Store.Tests/Controllers/CustomerControllerTests.cs +++ b/src/Tests/Grand.Web.Store.Tests/Controllers/CustomerControllerTests.cs @@ -226,6 +226,31 @@ public async Task ApplyPostConstraints_EditedSubAccount_KeepsItsRealOwner() Assert.AreEqual("boss@firm.com", model.Owner); } + [TestMethod] + public async Task ApplyPostConstraints_EditedCustomer_KeepsWhereItBelongs_IgnoringCraftedValues() + { + //blanking used to strip these on every save: the sales employee, the price groups, a colleague's + //Store manager role + _customerServiceMock.Setup(c => c.GetCustomerById("c1")).ReturnsAsync(new Customer { + Id = "c1", SeId = "se-1", VendorId = "vendor-1", StaffStoreId = StoreId, + Groups = { "registered-id", "wholesale-id", "storemanager-id" } + }); + var controller = BuildStoreController(); + + var model = new CustomerModel { + Id = "c1", SeId = "attacker-se", VendorId = "attacker-vendor", StaffStoreId = "attacker-store", + CustomerGroups = new[] { "attacker-admin-group-id" } + }; + await controller.ApplyPostConstraintsPublic(model); + + Assert.AreEqual(StoreId, model.StoreId); + Assert.AreEqual("se-1", model.SeId); + Assert.AreEqual("vendor-1", model.VendorId); + Assert.AreEqual(StoreId, model.StaffStoreId); + CollectionAssert.AreEqual(new[] { "registered-id", "wholesale-id", "storemanager-id" }, + model.CustomerGroups.ToArray()); + } + [TestMethod] public async Task ApplyPostConstraints_EditedCustomerWithoutOwner_GetsNone() { diff --git a/src/Web/Grand.Web.Store/Controllers/CustomerController.cs b/src/Web/Grand.Web.Store/Controllers/CustomerController.cs index 368e325a23..fcc3cbe08e 100644 --- a/src/Web/Grand.Web.Store/Controllers/CustomerController.cs +++ b/src/Web/Grand.Web.Store/Controllers/CustomerController.cs @@ -50,13 +50,27 @@ public class CustomerController( protected override async Task ApplyPostConstraints(CustomerModel model) { model.StoreId = Scope.DefaultStoreId; - //the owner link is not the store manager's to change: a new customer gets none and an existing one - //keeps its own (blanking it here used to detach every sub-account the store manager saved) + + //where a customer belongs - owner, vendor, staff store, sales employee, groups - is not the store + //manager's to change. An existing customer keeps what it has (blanking these used to strip them on every + //save: a sub-account lost its owner, a customer its sales employee and its price groups, a colleague + //the Store manager role); the posted values are never used either way var existing = string.IsNullOrEmpty(model.Id) ? null : await CustomerService.GetCustomerById(model.Id); - var owner = string.IsNullOrEmpty(existing?.OwnerId) - ? null - : await CustomerService.GetCustomerById(existing.OwnerId); - model.Owner = owner?.Email ?? ""; + if (existing != null) + { + var owner = string.IsNullOrEmpty(existing.OwnerId) + ? null + : await CustomerService.GetCustomerById(existing.OwnerId); + model.Owner = owner?.Email ?? ""; + model.VendorId = existing.VendorId ?? ""; + model.StaffStoreId = existing.StaffStoreId ?? ""; + model.SeId = existing.SeId ?? ""; + model.CustomerGroups = existing.Groups.ToArray(); + return; + } + + //a new customer belongs nowhere and is registered only + model.Owner = ""; model.VendorId = ""; model.StaffStoreId = ""; model.SeId = ""; From 04903652414dc212da5ec515b5d1d3390737f7b3 Mon Sep 17 00:00:00 2001 From: support Date: Sat, 24 Oct 2026 20:28:18 +0200 Subject: [PATCH 06/28] Store a customer's changed e-mail and username lowercased My account saved a changed e-mail or username as typed, while sign-in looks both up lowercased, so a customer who typed capitals could no longer sign in. Both are stored lowercased now, a value differing only in case repairs a row older edits stored as typed, and without usernames the username follows the e-mail as registration sets it. --- .../UpdateCustomerInfoCommandHandlerTests.cs | 84 ++++++++++++++++++- .../UpdateCustomerInfoCommandHandler.cs | 32 ++++--- 2 files changed, 102 insertions(+), 14 deletions(-) diff --git a/src/Tests/Grand.Web.Tests/Commands/Customers/UpdateCustomerInfoCommandHandlerTests.cs b/src/Tests/Grand.Web.Tests/Commands/Customers/UpdateCustomerInfoCommandHandlerTests.cs index b5b033ed89..3970c8b70c 100644 --- a/src/Tests/Grand.Web.Tests/Commands/Customers/UpdateCustomerInfoCommandHandlerTests.cs +++ b/src/Tests/Grand.Web.Tests/Commands/Customers/UpdateCustomerInfoCommandHandlerTests.cs @@ -10,6 +10,7 @@ using Grand.Web.Models.Customer; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; +using System.Linq.Expressions; namespace Grand.Web.Tests.Commands.Customers; @@ -18,17 +19,19 @@ public class UpdateCustomerInfoCommandHandlerTests { private Mock _customerService; - private async Task Update(OidcLoginConfig config) + private async Task Update(OidcLoginConfig config, CustomerSettings settings = null, + Customer customer = null, string email = "new@x.com", string username = null) { _customerService = new Mock(); var handler = new UpdateCustomerInfoCommandHandler(new Mock().Object, new Mock().Object, new Mock().Object, - _customerService.Object, new CustomerSettings { AllowUsersToChangeEmail = true }, new TaxSettings(), config); - var customer = new Customer { Id = "c1", Email = "old@x.com" }; + _customerService.Object, settings ?? new CustomerSettings { AllowUsersToChangeEmail = true }, + new TaxSettings(), config); + customer ??= new Customer { Id = "c1", Email = "old@x.com", Username = "old@x.com" }; await handler.Handle(new UpdateCustomerInfoCommand { Customer = customer, Store = new Domain.Stores.Store { Id = "s1" }, - Model = new CustomerInfoModel { Email = "new@x.com", FirstName = "A", LastName = "B" }, + Model = new CustomerInfoModel { Email = email, Username = username, FirstName = "A", LastName = "B" }, CustomerAttributes = [] }, CancellationToken.None); return customer; @@ -47,4 +50,77 @@ public async Task UpdateInfo_Mixed_EmailChanged() var customer = await Update(new OidcLoginConfig()); Assert.AreEqual("new@x.com", customer.Email); } + + [TestMethod] + public async Task UpdateInfo_MixedCaseEmail_IsStoredLowercased() + { + //stored as typed, the lowercased sign-in lookup missed it and the customer could no longer sign in + var customer = await Update(new OidcLoginConfig(), email: " New.Me@X.COM "); + + Assert.AreEqual("new.me@x.com", customer.Email); + _customerService.Verify(c => c.UpdateCustomerField(customer, + It.IsAny>>(), "new.me@x.com"), Times.AtLeastOnce); + } + + [TestMethod] + public async Task UpdateInfo_NewEmail_UsernamesDisabled_UsernameFollows() + { + var customer = await Update(new OidcLoginConfig()); + + Assert.AreEqual("new@x.com", customer.Username); + } + + [TestMethod] + public async Task UpdateInfo_SameEmailInOtherCase_ChangesNothing() + { + var customer = await Update(new OidcLoginConfig(), email: "OLD@X.com"); + + Assert.AreEqual("old@x.com", customer.Email); + _customerService.Verify(c => c.UpdateCustomerField(It.IsAny(), + It.IsAny>>(), It.IsAny()), Times.Never); + } + + [TestMethod] + [DataRow(false, OidcSignInMode.Mixed)] + [DataRow(true, OidcSignInMode.ProviderOnly)] + public async Task UpdateInfo_EmailStoredInMixedCaseByOlderEdits_IsRepaired(bool allowChange, OidcSignInMode mode) + { + //the same address, so repaired even where changing the e-mail is not allowed + var customer = await Update(new OidcLoginConfig { SignInMode = mode }, + new CustomerSettings { AllowUsersToChangeEmail = allowChange }, + new Customer { Id = "c1", Email = "Old@X.com", Username = "Old@X.com" }, email: "Old@X.com"); + + Assert.AreEqual("old@x.com", customer.Email); + } + + [TestMethod] + public async Task UpdateInfo_EmailChangeNotAllowed_OtherAddressIsNotTaken() + { + var customer = await Update(new OidcLoginConfig(), new CustomerSettings { AllowUsersToChangeEmail = false }); + + Assert.AreEqual("old@x.com", customer.Email); + } + + [TestMethod] + public async Task UpdateInfo_MixedCaseUsername_IsStoredLowercased() + { + var settings = new CustomerSettings { UsernamesEnabled = true, AllowUsersToChangeUsernames = true }; + var customer = await Update(new OidcLoginConfig(), settings, + new Customer { Id = "c1", Email = "old@x.com", Username = "old" }, email: "old@x.com", username: " John "); + + Assert.AreEqual("john", customer.Username); + //with usernames on, the e-mail does not drive the username + Assert.AreEqual("old@x.com", customer.Email); + } + + [TestMethod] + public async Task UpdateInfo_SameUsernameInOtherCase_ChangesNothing() + { + var settings = new CustomerSettings { UsernamesEnabled = true, AllowUsersToChangeUsernames = true }; + await Update(new OidcLoginConfig(), settings, + new Customer { Id = "c1", Email = "old@x.com", Username = "john" }, email: "old@x.com", username: "John"); + + _customerService.Verify(c => c.UpdateCustomerField(It.IsAny(), + It.IsAny>>(), It.IsAny()), Times.Never); + } } diff --git a/src/Web/Grand.Web/Commands/Handler/Customers/UpdateCustomerInfoCommandHandler.cs b/src/Web/Grand.Web/Commands/Handler/Customers/UpdateCustomerInfoCommandHandler.cs index 892b9acd5a..41a2d7e6c8 100644 --- a/src/Web/Grand.Web/Commands/Handler/Customers/UpdateCustomerInfoCommandHandler.cs +++ b/src/Web/Grand.Web/Commands/Handler/Customers/UpdateCustomerInfoCommandHandler.cs @@ -42,26 +42,38 @@ public UpdateCustomerInfoCommandHandler( public async Task Handle(UpdateCustomerInfoCommand request, CancellationToken cancellationToken) { - //username + //username - stored lowercased like every other write, or the lowercased sign-in lookup misses it + //(a value differing only in case is the stored one, so this repairs rows older edits stored as typed) if (_customerSettings.UsernamesEnabled && _customerSettings.AllowUsersToChangeUsernames) - if (!request.Customer.Username.Equals(request.Model.Username.Trim(), StringComparison.OrdinalIgnoreCase)) + { + var username = request.Model.Username.Trim().ToLowerInvariant(); + if (request.Customer.Username != username) { //change username - request.Customer.Username = request.Model.Username.Trim(); - await _customerService.UpdateCustomerField(request.Customer, x => x.Username, - request.Model.Username.Trim()); + request.Customer.Username = username; + await _customerService.UpdateCustomerField(request.Customer, x => x.Username, username); //re-authenticate if (request.OriginalCustomerIfImpersonated == null) await _authenticationService.SignIn(request.Customer, true); } + } - //email - if (!request.Customer.Email.Equals(request.Model.Email.Trim(), StringComparison.OrdinalIgnoreCase) && - _customerSettings.AllowUsersToChangeEmail && !_loginConfig.ProviderOnly) + //email - stored lowercased for the same reason. An address differing only in case is the same address, + //so it is repaired even where changing it is not allowed + var email = request.Model.Email.Trim().ToLowerInvariant(); + var sameAddress = string.Equals(request.Customer.Email, email, StringComparison.OrdinalIgnoreCase); + if (request.Customer.Email != email && + (sameAddress || (_customerSettings.AllowUsersToChangeEmail && !_loginConfig.ProviderOnly))) { //change email - request.Customer.Email = request.Model.Email.Trim(); - await _customerService.UpdateCustomerField(request.Customer, x => x.Email, request.Model.Email.Trim()); + request.Customer.Email = email; + await _customerService.UpdateCustomerField(request.Customer, x => x.Email, email); + //without usernames the username is the email (as registration sets it) + if (!_customerSettings.UsernamesEnabled) + { + request.Customer.Username = email; + await _customerService.UpdateCustomerField(request.Customer, x => x.Username, email); + } //re-authenticate (if usernames are disabled) //do not authenticate users in impersonation mode if (request.OriginalCustomerIfImpersonated == null) From 50485a6d674b0cd4c8016705f6544aa67c243405 Mon Sep 17 00:00:00 2001 From: support Date: Sun, 25 Oct 2026 00:09:11 +0200 Subject: [PATCH 07/28] Keep the Store panel to the store's customers, without staff accounts The Store panel is for a store's registered customers. Its scope also took in any registered account of the store, staff included: administrators, vendors, store managers, sales managers and accounts tied to a vendor or a staff store. These are now outside the panel - left out of the customer list in the query, so paging and totals stay right, and refused when opened by id, which covers every customer tab. GetAllCustomers gains two optional filters for this (excluded groups, and accounts tied to a vendor or a staff store); existing callers are unchanged. --- .../Interfaces/Customers/ICustomerService.cs | 5 +- .../Queries/Customers/GetCustomerQuery.cs | 7 +++ .../Handlers/GetCustomerQueryHandler.cs | 5 ++ .../Services/CustomerService.cs | 9 +++- .../Handlers/GetCustomerQueryHandlerTests.cs | 37 ++++++++++++++ .../StoreCustomerDataScopeTests.cs | 48 ++++++++++++++++++- .../Services/CustomerViewModelServiceTests.cs | 40 +++++++++++++++- .../Services/CustomerViewModelService.cs | 13 +++++ .../Services/StoreCustomerDataScope.cs | 15 ++++++ 9 files changed, 174 insertions(+), 5 deletions(-) diff --git a/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerService.cs b/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerService.cs index 7f64d4c29c..2106a6890c 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerService.cs @@ -43,6 +43,8 @@ public interface ICustomerService /// Page index /// Page size /// + /// Customers in any of these groups are left out + /// Leaves out accounts tied to a vendor or a staff store /// Customers Task> GetAllCustomers(DateTime? createdFromUtc = null, DateTime? createdToUtc = null, string affiliateId = "", string vendorId = "", string storeId = "", @@ -52,7 +54,8 @@ Task> GetAllCustomers(DateTime? createdFromUtc = null, string firstName = null, string lastName = null, string company = null, string phone = null, string zipPostalCode = null, bool loadOnlyWithShoppingCart = false, ShoppingCartType? sct = null, - int pageIndex = 0, int pageSize = int.MaxValue, Expression> orderBySelector = null); + int pageIndex = 0, int pageSize = int.MaxValue, Expression> orderBySelector = null, + string[] excludedCustomerGroupIds = null, bool excludeVendorAndStaffAccounts = false); /// diff --git a/src/Business/Grand.Business.Core/Queries/Customers/GetCustomerQuery.cs b/src/Business/Grand.Business.Core/Queries/Customers/GetCustomerQuery.cs index 0d4cbd1046..086ae33aa3 100644 --- a/src/Business/Grand.Business.Core/Queries/Customers/GetCustomerQuery.cs +++ b/src/Business/Grand.Business.Core/Queries/Customers/GetCustomerQuery.cs @@ -18,6 +18,13 @@ public class GetCustomerQuery : IRequest> public string SalesEmployeeId { get; set; } = ""; public string[] CustomerGroupIds { get; set; } = null; public string[] CustomerTagIds { get; set; } = null; + + /// Customers in any of these groups are left out + public string[] ExcludedCustomerGroupIds { get; set; } = null; + + /// Leaves out accounts tied to a vendor or a staff store + public bool ExcludeVendorAndStaffAccounts { get; set; } = false; + public string Email { get; set; } = null; public string Username { get; set; } = null; public string FirstName { get; set; } = null; diff --git a/src/Business/Grand.Business.Customers/Queries/Handlers/GetCustomerQueryHandler.cs b/src/Business/Grand.Business.Customers/Queries/Handlers/GetCustomerQueryHandler.cs index c59cabdc86..6502b610ca 100644 --- a/src/Business/Grand.Business.Customers/Queries/Handlers/GetCustomerQueryHandler.cs +++ b/src/Business/Grand.Business.Customers/Queries/Handlers/GetCustomerQueryHandler.cs @@ -39,6 +39,11 @@ public async Task> Handle(GetCustomerQuery request, Cancell query = query.Where(c => !c.Deleted); if (request.CustomerGroupIds is { Length: > 0 }) query = query.Where(c => c.Groups.Any(x => request.CustomerGroupIds.Contains(x))); + if (request.ExcludedCustomerGroupIds is { Length: > 0 }) + query = query.Where(c => !c.Groups.Any(x => request.ExcludedCustomerGroupIds.Contains(x))); + if (request.ExcludeVendorAndStaffAccounts) + query = query.Where(c => (c.VendorId == null || c.VendorId == "") && + (c.StaffStoreId == null || c.StaffStoreId == "")); if (request.CustomerTagIds is { Length: > 0 }) foreach (var item in request.CustomerTagIds) query = query.Where(c => c.CustomerTags.Contains(item)); diff --git a/src/Business/Grand.Business.Customers/Services/CustomerService.cs b/src/Business/Grand.Business.Customers/Services/CustomerService.cs index da23c7bd06..6fe3747ebd 100644 --- a/src/Business/Grand.Business.Customers/Services/CustomerService.cs +++ b/src/Business/Grand.Business.Customers/Services/CustomerService.cs @@ -79,6 +79,8 @@ public CustomerService( /// Page index /// Page size /// order by selector + /// Customers in any of these groups are left out + /// Leaves out accounts tied to a vendor or a staff store /// Customers public virtual async Task> GetAllCustomers(DateTime? createdFromUtc = null, DateTime? createdToUtc = null, string affiliateId = "", string vendorId = "", string storeId = "", @@ -88,7 +90,8 @@ public virtual async Task> GetAllCustomers(DateTime? create string firstName = null, string lastName = null, string company = null, string phone = null, string zipPostalCode = null, bool loadOnlyWithShoppingCart = false, ShoppingCartType? sct = null, - int pageIndex = 0, int pageSize = 2147483647, Expression> orderBySelector = null) + int pageIndex = 0, int pageSize = 2147483647, Expression> orderBySelector = null, + string[] excludedCustomerGroupIds = null, bool excludeVendorAndStaffAccounts = false) { var queryModel = new GetCustomerQuery { CreatedFromUtc = createdFromUtc, @@ -111,7 +114,9 @@ public virtual async Task> GetAllCustomers(DateTime? create Sct = sct, PageIndex = pageIndex, PageSize = pageSize, - OrderBySelector = orderBySelector + OrderBySelector = orderBySelector, + ExcludedCustomerGroupIds = excludedCustomerGroupIds, + ExcludeVendorAndStaffAccounts = excludeVendorAndStaffAccounts }; return await _mediator.Send(queryModel); } diff --git a/src/Tests/Grand.Business.Customers.Tests/Queries/Handlers/GetCustomerQueryHandlerTests.cs b/src/Tests/Grand.Business.Customers.Tests/Queries/Handlers/GetCustomerQueryHandlerTests.cs index dd8b1af5b6..cfdfd07cf8 100644 --- a/src/Tests/Grand.Business.Customers.Tests/Queries/Handlers/GetCustomerQueryHandlerTests.cs +++ b/src/Tests/Grand.Business.Customers.Tests/Queries/Handlers/GetCustomerQueryHandlerTests.cs @@ -46,4 +46,41 @@ public async Task Handle_PageSizeTwo_ReturnsFirstPageAndTotal() Assert.AreEqual(4, result.TotalCount); Assert.AreEqual(2, result.TotalPages); } + + [TestMethod] + public async Task Handle_ExcludedGroupsAndStaffAccounts_LeftOutInTheQuery() + { + var repository = new MongoDBRepositoryTest(); + var customer = new Customer { Email = "customer@x.com", Groups = { "registered" } }; + var wholesale = new Customer { Email = "wholesale@x.com", Groups = { "registered", "wholesale" } }; + repository.Insert(customer); + repository.Insert(wholesale); + repository.Insert(new Customer { Email = "vendor-group@x.com", Groups = { "registered", "vendors" } }); + repository.Insert(new Customer { Email = "manager-group@x.com", Groups = { "registered", "storemanager" } }); + repository.Insert(new Customer { Email = "vendor-link@x.com", Groups = { "registered" }, VendorId = "v1" }); + repository.Insert(new Customer { Email = "staff-link@x.com", Groups = { "registered" }, StaffStoreId = "s1" }); + var queryHandler = new GetCustomerQueryHandler(repository); + + var result = await queryHandler.Handle(new GetCustomerQuery { + ExcludedCustomerGroupIds = ["vendors", "storemanager"], + ExcludeVendorAndStaffAccounts = true + }, CancellationToken.None); + + CollectionAssert.AreEquivalent(new[] { "customer@x.com", "wholesale@x.com" }, + result.Select(c => c.Email).ToArray()); + Assert.AreEqual(2, result.TotalCount); + } + + [TestMethod] + public async Task Handle_NoExclusions_KeepsEveryone() + { + var repository = new MongoDBRepositoryTest(); + repository.Insert(new Customer { Groups = { "vendors" }, VendorId = "v1" }); + repository.Insert(new Customer { StaffStoreId = "s1" }); + + var result = await new GetCustomerQueryHandler(repository).Handle(new GetCustomerQuery(), + CancellationToken.None); + + Assert.AreEqual(2, result.TotalCount); + } } \ No newline at end of file diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/StoreCustomerDataScopeTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/StoreCustomerDataScopeTests.cs index 95a0335b29..0c4c603054 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/StoreCustomerDataScopeTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/StoreCustomerDataScopeTests.cs @@ -10,7 +10,8 @@ namespace Grand.Web.Admin.Tests.Controllers; [TestClass] public class StoreCustomerDataScopeTests { - private static StoreCustomerDataScope Build(string staffStoreId, bool isRegistered = true) + private static StoreCustomerDataScope Build(string staffStoreId, bool isRegistered = true, + Action> groups = null) { var customer = new Customer { StaffStoreId = staffStoreId }; var workContextMock = new Mock(); @@ -20,10 +21,55 @@ private static StoreCustomerDataScope Build(string staffStoreId, bool isRegister var groupServiceMock = new Mock(); groupServiceMock.Setup(g => g.IsRegistered(It.IsAny())).ReturnsAsync(isRegistered); + groups?.Invoke(groupServiceMock); return new StoreCustomerDataScope(contextAccessorMock.Object, groupServiceMock.Object); } + //a store manager manages registered customers only: a staff account in reach could be reset and taken over + + [TestMethod] + [DataRow("admin")] + [DataRow("vendor")] + [DataRow("storemanager")] + [DataRow("salesmanager")] + public async Task HasAccess_StaffGroupMember_False(string role) + { + var scope = Build("store-1", groups: g => { + g.Setup(x => x.IsAdmin(It.IsAny())).ReturnsAsync(role == "admin"); + g.Setup(x => x.IsVendor(It.IsAny())).ReturnsAsync(role == "vendor"); + g.Setup(x => x.IsStoreManager(It.IsAny())).ReturnsAsync(role == "storemanager"); + g.Setup(x => x.IsSalesManager(It.IsAny())).ReturnsAsync(role == "salesmanager"); + }); + + Assert.IsFalse(await scope.HasAccess(new Customer { StoreId = "store-1" })); + } + + [TestMethod] + public async Task HasAccess_TiedToAVendor_False() + { + var scope = Build("store-1"); + + Assert.IsFalse(await scope.HasAccess(new Customer { StoreId = "store-1", VendorId = "vendor-1" })); + } + + [TestMethod] + public async Task HasAccess_TiedToAStaffStore_False() + { + var scope = Build("store-1"); + + Assert.IsFalse(await scope.HasAccess(new Customer { StoreId = "store-1", StaffStoreId = "store-1" })); + } + + [TestMethod] + public void StaffGroupSystemNames_CoverEveryStaffRole() + { + CollectionAssert.AreEquivalent(new[] { + SystemCustomerGroupNames.Administrators, SystemCustomerGroupNames.Vendors, + SystemCustomerGroupNames.StoreManager, SystemCustomerGroupNames.SalesManager + }, StoreCustomerDataScope.StaffGroupSystemNames); + } + [TestMethod] public async Task HasAccess_MatchingStoreId_Registered_NotDeleted_True() { diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs index 363f4f39fd..9cf0c5282b 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs @@ -445,6 +445,43 @@ public async Task PrepareCustomerList_NoCustomers_ReturnsEmpty() Assert.AreEqual(0, list.Count()); } + #region Store panel list + + private void VerifyListQuery(string[] excludedGroupIds, bool excludeVendorAndStaff) => + _customerServiceMock.Verify(c => c.GetAllCustomers(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>>(), + It.Is(ids => excludedGroupIds == null + ? ids == null + : ids != null && ids.OrderBy(x => x).SequenceEqual(excludedGroupIds.OrderBy(x => x))), + excludeVendorAndStaff), Times.Once); + + [TestMethod] + public async Task PrepareCustomerList_StorePanel_LeavesTheStaffOutInTheQuery() + { + _scopeMock.Setup(s => s.GetDefaultStoreIdAsync()).ReturnsAsync("store-7"); + foreach (var name in StoreCustomerDataScope.StaffGroupSystemNames) + _groupServiceMock.Setup(g => g.GetCustomerGroupBySystemName(name)) + .ReturnsAsync(new CustomerGroup { Id = "id-" + name, SystemName = name }); + + await _customerViewModelService.PrepareCustomerList(new CustomerListModel(), null, null, 1, 10, "store-7"); + + VerifyListQuery(StoreCustomerDataScope.StaffGroupSystemNames.Select(n => "id-" + n).ToArray(), true); + } + + [TestMethod] + public async Task PrepareCustomerList_AdminPanel_ExcludesNobody() + { + await _customerViewModelService.PrepareCustomerList(new CustomerListModel(), null, null, 1, 10); + + VerifyListQuery(null, false); + } + + #endregion + #region Owner and sub-accounts [TestMethod] @@ -621,7 +658,8 @@ private void SetupGetAllCustomers(IPagedList result) It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny>>())) + It.IsAny>>(), + It.IsAny(), It.IsAny())) .ReturnsAsync(result); } } diff --git a/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs index e4fd2f596b..f9a4a61e30 100644 --- a/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs @@ -124,7 +124,20 @@ public virtual async Task PrepareCustomerListModel() { var salesEmployeeId = _contextAccessor.WorkContext.CurrentCustomer.SeId; + //the Store panel lists its store's customers, never the staff (see StoreCustomerDataScope); left out in the + //query, so paging and the total stay right + var staffExcluded = await _scope.GetDefaultStoreIdAsync() is not null; + var excludedGroupIds = new List(); + if (staffExcluded) + foreach (var systemName in StoreCustomerDataScope.StaffGroupSystemNames) + { + var group = await _groupService.GetCustomerGroupBySystemName(systemName); + if (group != null) excludedGroupIds.Add(group.Id); + } + var customers = await _customerService.GetAllCustomers( + excludedCustomerGroupIds: staffExcluded ? excludedGroupIds.ToArray() : null, + excludeVendorAndStaffAccounts: staffExcluded, storeId: storeId, customerGroupIds: searchCustomerGroupIds, customerTagIds: searchCustomerTagIds, diff --git a/src/Web/Grand.Web.AdminShared/Services/StoreCustomerDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/StoreCustomerDataScope.cs index bca8cd55e1..f1345bf105 100644 --- a/src/Web/Grand.Web.AdminShared/Services/StoreCustomerDataScope.cs +++ b/src/Web/Grand.Web.AdminShared/Services/StoreCustomerDataScope.cs @@ -6,13 +6,28 @@ using Grand.Web.AdminShared.Interfaces; namespace Grand.Web.AdminShared.Services; +/// +/// The Store panel manages its store's registered customers and nothing more: never the staff - administrators, +/// vendors, store managers, sales managers, or any account tied to a vendor or a staff store - whose accounts +/// a store manager could otherwise reset and take over. +/// public class StoreCustomerDataScope(IContextAccessor contextAccessor, IGroupService groupService) : IAdminDataScope { + /// Groups whose members are staff, left out of the Store panel's customer list + public static readonly string[] StaffGroupSystemNames = [ + SystemCustomerGroupNames.Administrators, SystemCustomerGroupNames.Vendors, + SystemCustomerGroupNames.StoreManager, SystemCustomerGroupNames.SalesManager + ]; + public async Task HasAccess(Customer entity) { if (entity is null || entity.Deleted) return false; if (entity.StoreId != contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) return false; + if (!string.IsNullOrEmpty(entity.VendorId) || !string.IsNullOrEmpty(entity.StaffStoreId)) return false; + if (await groupService.IsAdmin(entity) || await groupService.IsVendor(entity) || + await groupService.IsStoreManager(entity) || await groupService.IsSalesManager(entity)) + return false; return await groupService.IsRegistered(entity); } From 4ac5e9497e5299e498535539ab22c94377e928d8 Mon Sep 17 00:00:00 2001 From: support Date: Sun, 25 Oct 2026 07:44:43 +0100 Subject: [PATCH 08/28] Add order approval entity, service and checkout state --- .../Orders/OrderApprovalFingerprint.cs | 61 +++ .../Services/Orders/OrderApprovalService.cs | 143 ++++++ .../Startup/StartupApplication.cs | 1 + .../Checkout/Orders/IOrderApprovalService.cs | 69 +++ .../Checkout/OrderApprovalCheckoutState.cs | 21 + .../Utilities/Checkout/OrderApprovalRules.cs | 29 ++ src/Core/Grand.Domain/Customers/Customer.cs | 11 + .../Grand.Domain/Customers/SubAccountRole.cs | 17 + src/Core/Grand.Domain/Orders/OrderApproval.cs | 72 +++ .../Grand.Domain/Orders/OrderApprovalItem.cs | 42 ++ .../Orders/OrderApprovalStatus.cs | 32 ++ .../Orders/OrderApprovalSummary.cs | 38 ++ .../Services/InstallationService.cs | 11 + .../Orders/OrderApprovalFingerprintTests.cs | 136 ++++++ .../Orders/OrderApprovalServiceTests.cs | 431 ++++++++++++++++++ 15 files changed, 1114 insertions(+) create mode 100644 src/Business/Grand.Business.Checkout/Services/Orders/OrderApprovalFingerprint.cs create mode 100644 src/Business/Grand.Business.Checkout/Services/Orders/OrderApprovalService.cs create mode 100644 src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IOrderApprovalService.cs create mode 100644 src/Business/Grand.Business.Core/Utilities/Checkout/OrderApprovalCheckoutState.cs create mode 100644 src/Business/Grand.Business.Core/Utilities/Checkout/OrderApprovalRules.cs create mode 100644 src/Core/Grand.Domain/Customers/SubAccountRole.cs create mode 100644 src/Core/Grand.Domain/Orders/OrderApproval.cs create mode 100644 src/Core/Grand.Domain/Orders/OrderApprovalItem.cs create mode 100644 src/Core/Grand.Domain/Orders/OrderApprovalStatus.cs create mode 100644 src/Core/Grand.Domain/Orders/OrderApprovalSummary.cs create mode 100644 src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderApprovalFingerprintTests.cs create mode 100644 src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderApprovalServiceTests.cs diff --git a/src/Business/Grand.Business.Checkout/Services/Orders/OrderApprovalFingerprint.cs b/src/Business/Grand.Business.Checkout/Services/Orders/OrderApprovalFingerprint.cs new file mode 100644 index 0000000000..c89bca8c15 --- /dev/null +++ b/src/Business/Grand.Business.Checkout/Services/Orders/OrderApprovalFingerprint.cs @@ -0,0 +1,61 @@ +using Grand.Domain.Orders; +using System.Globalization; +using System.Security.Cryptography; + +namespace Grand.Business.Checkout.Services.Orders; + +/// +/// Identifies the content of a shopping cart, so that an approval holds for exactly the cart it was given for +/// +public static class OrderApprovalFingerprint +{ + /// + /// Computes the SHA-256 (hex) of the shopping cart items: product, quantity, attributes, rental dates, + /// entered price and warehouse. Wishlist items are ignored; the order of items and attributes does not matter. + /// + /// Shopping cart items + public static string Compute(IEnumerable cart) + { + ArgumentNullException.ThrowIfNull(cart); + + // every line starts with the product id, so sorting the lines sorts by product first and keeps + // items of one product with different attributes in a stable order + var lines = cart + .Where(x => x.ShoppingCartTypeId == ShoppingCartType.ShoppingCart) + .Select(Line) + .OrderBy(x => x, StringComparer.Ordinal); + + var canonical = string.Concat(lines); + return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))); + } + + private static string Line(ShoppingCartItem item) + { + var attributes = (item.Attributes ?? []) + .OrderBy(x => x.Key, StringComparer.Ordinal) + .ThenBy(x => x.Value, StringComparer.Ordinal) + .Select(x => Field(x.Key) + Field(x.Value)); + + return Field(item.ProductId) + + Field(item.Quantity.ToString(CultureInfo.InvariantCulture)) + + Field(string.Concat(attributes)) + + Field(Date(item.RentalStartDateUtc)) + + Field(Date(item.RentalEndDateUtc)) + + Field(item.EnteredPrice?.ToString("R", CultureInfo.InvariantCulture)) + + Field(item.WarehouseId) + + ";"; + } + + // length prefix: no value can run into the next one and produce the same text from other data + private static string Field(string value) + { + return value is null ? "-;" : $"{value.Length}:{value};"; + } + + private static string Date(DateTime? value) + { + if (!value.HasValue) return null; + var utc = value.Value.Kind == DateTimeKind.Local ? value.Value.ToUniversalTime() : value.Value; + return DateTime.SpecifyKind(utc, DateTimeKind.Utc).ToString("o", CultureInfo.InvariantCulture); + } +} diff --git a/src/Business/Grand.Business.Checkout/Services/Orders/OrderApprovalService.cs b/src/Business/Grand.Business.Checkout/Services/Orders/OrderApprovalService.cs new file mode 100644 index 0000000000..1c89cea404 --- /dev/null +++ b/src/Business/Grand.Business.Checkout/Services/Orders/OrderApprovalService.cs @@ -0,0 +1,143 @@ +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Utilities.Checkout; +using Grand.Data; +using Grand.Domain; +using Grand.Domain.Customers; +using Grand.Domain.Orders; +using Grand.Infrastructure.Extensions; +using Grand.Mediator; + +namespace Grand.Business.Checkout.Services.Orders; + +/// +/// Order approval service +/// +public class OrderApprovalService : IOrderApprovalService +{ + #region Ctor + + public OrderApprovalService(IRepository repository, + IOrderCalculationService orderCalculationService, + CustomerSettings customerSettings, + IMediator mediator) + { + _repository = repository; + _orderCalculationService = orderCalculationService; + _customerSettings = customerSettings; + _mediator = mediator; + } + + #endregion + + #region Fields + + private readonly IRepository _repository; + private readonly IOrderCalculationService _orderCalculationService; + private readonly CustomerSettings _customerSettings; + private readonly IMediator _mediator; + + #endregion + + #region Methods + + public virtual async Task GetById(string id, string companyOwnerId) + { + if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(companyOwnerId)) + return null; + + return await _repository.FirstOrDefaultAsync( + _repository.Table.Where(x => x.Id == id && x.CompanyOwnerId == companyOwnerId)); + } + + public virtual async Task GetActive(string customerId, string storeId) + { + if (string.IsNullOrEmpty(customerId)) + return null; + + return await _repository.FirstOrDefaultAsync( + _repository.Table + .Where(x => x.CustomerId == customerId && x.StoreId == storeId && + (x.Status == OrderApprovalStatus.Pending || x.Status == OrderApprovalStatus.Approved)) + .OrderByDescending(x => x.CreatedOnUtc)); + } + + public virtual async Task> GetForCompany(string companyOwnerId, string storeId, + int pageIndex, int pageSize) + { + var query = _repository.Table + .Where(x => x.CompanyOwnerId == companyOwnerId && x.StoreId == storeId) + .OrderBy(x => x.Status == OrderApprovalStatus.Pending ? 0 : 1) + .ThenByDescending(x => x.CreatedOnUtc); + + return await _repository.PagedAsync(query, pageIndex, pageSize); + } + + public virtual async Task> GetForCustomer(string customerId, string storeId, + int pageIndex, int pageSize) + { + var query = _repository.Table + .Where(x => x.CustomerId == customerId && x.StoreId == storeId) + .OrderByDescending(x => x.CreatedOnUtc); + + return await _repository.PagedAsync(query, pageIndex, pageSize); + } + + public virtual async Task Insert(OrderApproval approval) + { + ArgumentNullException.ThrowIfNull(approval); + + await _repository.InsertAsync(approval); + await _mediator.EntityInserted(approval); + } + + public virtual async Task Update(OrderApproval approval) + { + ArgumentNullException.ThrowIfNull(approval); + + await _repository.UpdateAsync(approval); + await _mediator.EntityUpdated(approval); + } + + public virtual async Task GetCheckoutState(IList cart, + CalculationContext context) + { + ArgumentNullException.ThrowIfNull(cart); + ArgumentNullException.ThrowIfNull(context); + + var customer = context.Customer; + var limit = customer.OrderApprovalLimit; + + // not a limited buyer: nothing about approvals applies (also when the sub-account feature is off) + if (_customerSettings.HideSubAccountsTab || string.IsNullOrEmpty(customer.OwnerId) || + OrderApprovalRules.CanApprove(customer) || !limit.HasValue) + return new OrderApprovalCheckoutState(false, limit, null, null); + + var total = (await _orderCalculationService.GetShoppingCartTotal(cart, context))?.Total; + + // the total is only known once shipping is chosen; until then the rule is not evaluated + if (!total.HasValue || total.Value <= limit.Value) + return new OrderApprovalCheckoutState(false, limit, total, null); + + var fingerprint = OrderApprovalFingerprint.Compute(cart); + var storeId = context.Store.Id; + var amount = total.Value; + + var approved = await _repository.FirstOrDefaultAsync( + _repository.Table + .Where(x => x.CustomerId == customer.Id && x.StoreId == storeId && + x.Status == OrderApprovalStatus.Approved && x.CartFingerprint == fingerprint && + x.Amount >= amount) + .OrderByDescending(x => x.CreatedOnUtc)); + if (approved != null) + return new OrderApprovalCheckoutState(true, limit, total, approved); + + var pending = await _repository.FirstOrDefaultAsync( + _repository.Table + .Where(x => x.CustomerId == customer.Id && x.StoreId == storeId && + x.Status == OrderApprovalStatus.Pending) + .OrderByDescending(x => x.CreatedOnUtc)); + return new OrderApprovalCheckoutState(true, limit, total, pending); + } + + #endregion +} diff --git a/src/Business/Grand.Business.Checkout/Startup/StartupApplication.cs b/src/Business/Grand.Business.Checkout/Startup/StartupApplication.cs index 84c5b9659f..ceec4b8b39 100644 --- a/src/Business/Grand.Business.Checkout/Startup/StartupApplication.cs +++ b/src/Business/Grand.Business.Checkout/Startup/StartupApplication.cs @@ -50,6 +50,7 @@ private static void RegisterOrdersService(IServiceCollection serviceCollection) serviceCollection.AddScoped(); serviceCollection.AddScoped(); serviceCollection.AddScoped(); + serviceCollection.AddScoped(); serviceCollection.AddScoped(); serviceCollection.AddScoped(); serviceCollection.AddScoped(); diff --git a/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IOrderApprovalService.cs b/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IOrderApprovalService.cs new file mode 100644 index 0000000000..1485d68368 --- /dev/null +++ b/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IOrderApprovalService.cs @@ -0,0 +1,69 @@ +using Grand.Business.Core.Utilities.Checkout; +using Grand.Domain; +using Grand.Domain.Orders; + +namespace Grand.Business.Core.Interfaces.Checkout.Orders; + +/// +/// Order approval service: a company sub-account's cart is approved before the order is placed +/// +public interface IOrderApprovalService +{ + /// + /// Gets an approval of the company + /// + /// Approval identifier + /// Identifier of the company owner the approval must belong to + /// Approval; null when it does not exist or belongs to another company + Task GetById(string id, string companyOwnerId); + + /// + /// Gets the newest pending or approved request of the customer in the store + /// + /// Buyer identifier + /// Store identifier + /// Approval; null when there is none + Task GetActive(string customerId, string storeId); + + /// + /// Gets the company's requests in the store, pending first and then the newest + /// + /// Identifier of the company owner + /// Store identifier + /// Page index + /// Page size + Task> GetForCompany(string companyOwnerId, string storeId, int pageIndex, + int pageSize); + + /// + /// Gets the customer's requests in the store, the newest first + /// + /// Buyer identifier + /// Store identifier + /// Page index + /// Page size + Task> GetForCustomer(string customerId, string storeId, int pageIndex, + int pageSize); + + /// + /// Inserts an approval + /// + /// Approval + Task Insert(OrderApproval approval); + + /// + /// Updates an approval + /// + /// Approval + Task Update(OrderApproval approval); + + /// + /// Works out whether the cart needs an approval before the order is placed. + /// An approval is needed when the customer is a sub-account buyer with an order limit, the sub-account + /// feature is on, the order total is known and exceeds the limit. It is satisfied by an approved request + /// of the same customer and store for the same cart whose amount is not below the current total. + /// + /// Shopping cart of the customer + /// Customer, store and currency the cart is calculated for + Task GetCheckoutState(IList cart, CalculationContext context); +} diff --git a/src/Business/Grand.Business.Core/Utilities/Checkout/OrderApprovalCheckoutState.cs b/src/Business/Grand.Business.Core/Utilities/Checkout/OrderApprovalCheckoutState.cs new file mode 100644 index 0000000000..8d9c6ee732 --- /dev/null +++ b/src/Business/Grand.Business.Core/Utilities/Checkout/OrderApprovalCheckoutState.cs @@ -0,0 +1,21 @@ +using Grand.Domain.Orders; + +namespace Grand.Business.Core.Utilities.Checkout; + +/// +/// Whether a cart needs an order approval before the order can be placed, and where that approval stands +/// +/// The customer is a limited buyer and the order total exceeds the limit +/// The customer's order limit in the primary store currency; null when there is none +/// The order total in the primary store currency; null when it is not known yet +/// +/// The approval covering the cart when there is one, otherwise the customer's pending request; null when neither +/// exists +/// +public record OrderApprovalCheckoutState(bool Required, double? Limit, double? Total, OrderApproval Active) +{ + /// + /// Gets a value indicating whether the order must not be placed yet + /// + public bool Blocked => Required && Active?.Status != OrderApprovalStatus.Approved; +} diff --git a/src/Business/Grand.Business.Core/Utilities/Checkout/OrderApprovalRules.cs b/src/Business/Grand.Business.Core/Utilities/Checkout/OrderApprovalRules.cs new file mode 100644 index 0000000000..e9ae52d3a6 --- /dev/null +++ b/src/Business/Grand.Business.Core/Utilities/Checkout/OrderApprovalRules.cs @@ -0,0 +1,29 @@ +using Grand.Domain.Customers; + +namespace Grand.Business.Core.Utilities.Checkout; + +/// +/// Who belongs to which company and who may decide its order approvals +/// +public static class OrderApprovalRules +{ + /// + /// Gets the identifier of the company the customer buys for: the owner's id for a sub-account, + /// the customer's own id otherwise + /// + public static string CompanyId(Customer customer) + { + ArgumentNullException.ThrowIfNull(customer); + return string.IsNullOrEmpty(customer.OwnerId) ? customer.Id : customer.OwnerId; + } + + /// + /// Gets a value indicating whether the customer may decide the company's approvals: the company owner + /// or an approver sub-account + /// + public static bool CanApprove(Customer customer) + { + ArgumentNullException.ThrowIfNull(customer); + return string.IsNullOrEmpty(customer.OwnerId) || customer.SubAccountRole == SubAccountRole.Approver; + } +} diff --git a/src/Core/Grand.Domain/Customers/Customer.cs b/src/Core/Grand.Domain/Customers/Customer.cs index 8379d352c0..c45ef32e1f 100644 --- a/src/Core/Grand.Domain/Customers/Customer.cs +++ b/src/Core/Grand.Domain/Customers/Customer.cs @@ -93,6 +93,17 @@ public Customer() /// public string OwnerId { get; set; } + /// + /// Gets or sets the purchasing role of a sub-account; meaningful only when is set + /// + public SubAccountRole SubAccountRole { get; set; } + + /// + /// Gets or sets the order limit of a buyer sub-account, in the primary store currency; + /// null means no limit + /// + public double? OrderApprovalLimit { get; set; } + /// /// Gets or sets the sales employee identifier /// diff --git a/src/Core/Grand.Domain/Customers/SubAccountRole.cs b/src/Core/Grand.Domain/Customers/SubAccountRole.cs new file mode 100644 index 0000000000..e1ed199476 --- /dev/null +++ b/src/Core/Grand.Domain/Customers/SubAccountRole.cs @@ -0,0 +1,17 @@ +namespace Grand.Domain.Customers; + +/// +/// Purchasing role of a company sub-account +/// +public enum SubAccountRole +{ + /// + /// Orders above the account's order limit need an approval + /// + Buyer = 0, + + /// + /// Approves the company's order requests and orders without a limit + /// + Approver = 10 +} diff --git a/src/Core/Grand.Domain/Orders/OrderApproval.cs b/src/Core/Grand.Domain/Orders/OrderApproval.cs new file mode 100644 index 0000000000..dc911a143e --- /dev/null +++ b/src/Core/Grand.Domain/Orders/OrderApproval.cs @@ -0,0 +1,72 @@ +namespace Grand.Domain.Orders; + +/// +/// Represents a request to approve a company sub-account's cart before the order is placed +/// +public class OrderApproval : BaseEntity +{ + /// + /// Gets or sets the store identifier the cart belongs to + /// + public string StoreId { get; set; } + + /// + /// Gets or sets the identifier of the company owner; every read is scoped by it + /// + public string CompanyOwnerId { get; set; } + + /// + /// Gets or sets the buyer identifier + /// + public string CustomerId { get; set; } + + /// + /// Gets or sets the status + /// + public OrderApprovalStatus Status { get; set; } + + /// + /// Gets or sets the hash of the approved cart + /// + public string CartFingerprint { get; set; } + + /// + /// Gets or sets the cart lines as the approver sees them + /// + public IList Items { get; set; } = new List(); + + /// + /// Gets or sets the order totals as the buyer saw them + /// + public OrderApprovalSummary Summary { get; set; } = new(); + + /// + /// Gets or sets the order total compared with the limit, in the primary store currency + /// + public double Amount { get; set; } + + /// + /// Gets or sets the buyer's currency code, for display + /// + public string CurrencyCode { get; set; } + + /// + /// Gets or sets the date and time of the decision + /// + public DateTime? DecidedOnUtc { get; set; } + + /// + /// Gets or sets the identifier of the customer who decided + /// + public string DecidedByCustomerId { get; set; } + + /// + /// Gets or sets the approver's comment + /// + public string DecisionComment { get; set; } + + /// + /// Gets or sets the identifier of the order that used the approval + /// + public string OrderId { get; set; } +} diff --git a/src/Core/Grand.Domain/Orders/OrderApprovalItem.cs b/src/Core/Grand.Domain/Orders/OrderApprovalItem.cs new file mode 100644 index 0000000000..6fd400c6c3 --- /dev/null +++ b/src/Core/Grand.Domain/Orders/OrderApprovalItem.cs @@ -0,0 +1,42 @@ +namespace Grand.Domain.Orders; + +/// +/// Represents a cart line as the approver sees it +/// +public class OrderApprovalItem : SubBaseEntity +{ + /// + /// Gets or sets the product identifier + /// + public string ProductId { get; set; } + + /// + /// Gets or sets the product name + /// + public string ProductName { get; set; } + + /// + /// Gets or sets the product SKU + /// + public string Sku { get; set; } + + /// + /// Gets or sets the quantity + /// + public int Quantity { get; set; } + + /// + /// Gets or sets the formatted attributes + /// + public string AttributeDescription { get; set; } + + /// + /// Gets or sets the formatted unit price, in the buyer's currency + /// + public string UnitPrice { get; set; } + + /// + /// Gets or sets the formatted line total, in the buyer's currency + /// + public string LineTotal { get; set; } +} diff --git a/src/Core/Grand.Domain/Orders/OrderApprovalStatus.cs b/src/Core/Grand.Domain/Orders/OrderApprovalStatus.cs new file mode 100644 index 0000000000..58b6d418d8 --- /dev/null +++ b/src/Core/Grand.Domain/Orders/OrderApprovalStatus.cs @@ -0,0 +1,32 @@ +namespace Grand.Domain.Orders; + +/// +/// Represents the state of an order approval request +/// +public enum OrderApprovalStatus +{ + /// + /// Waiting for a decision + /// + Pending = 0, + + /// + /// Approved, not yet used by an order + /// + Approved = 10, + + /// + /// Rejected by an approver + /// + Rejected = 20, + + /// + /// Withdrawn by the buyer + /// + Withdrawn = 30, + + /// + /// Consumed by a placed order + /// + Used = 40 +} diff --git a/src/Core/Grand.Domain/Orders/OrderApprovalSummary.cs b/src/Core/Grand.Domain/Orders/OrderApprovalSummary.cs new file mode 100644 index 0000000000..bf1b0bac62 --- /dev/null +++ b/src/Core/Grand.Domain/Orders/OrderApprovalSummary.cs @@ -0,0 +1,38 @@ +namespace Grand.Domain.Orders; + +/// +/// Represents the order totals as the buyer saw them on the confirm step; all values are formatted +/// in the buyer's currency +/// +public class OrderApprovalSummary +{ + /// + /// Gets or sets the sub total + /// + public string SubTotal { get; set; } + + /// + /// Gets or sets the discount + /// + public string Discount { get; set; } + + /// + /// Gets or sets the shipping method name + /// + public string ShippingMethod { get; set; } + + /// + /// Gets or sets the shipping cost + /// + public string Shipping { get; set; } + + /// + /// Gets or sets the tax + /// + public string Tax { get; set; } + + /// + /// Gets or sets the order total + /// + public string Total { get; set; } +} diff --git a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs index 3a45b80638..fc3e18d1e0 100644 --- a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs +++ b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs @@ -59,6 +59,7 @@ public InstallationService( IRepository campaignRepository, IRepository orderRepository, IRepository orderNoteRepository, + IRepository orderApprovalRepository, IRepository merchandiseReturnRepository, IRepository merchandiseReturnNoteRepository, IRepository storeRepository, @@ -156,6 +157,7 @@ public InstallationService( _campaignHistoryRepository = campaignHistoryRepository; _campaignRepository = campaignRepository; _orderRepository = orderRepository; + _orderApprovalRepository = orderApprovalRepository; _orderNoteRepository = orderNoteRepository; _storeRepository = storeRepository; _measureDimensionRepository = measureDimensionRepository; @@ -322,6 +324,7 @@ public virtual async Task InstallData(string httpscheme, HostString host, string private readonly IRepository _campaignHistoryRepository; private readonly IRepository _campaignRepository; private readonly IRepository _orderRepository; + private readonly IRepository _orderApprovalRepository; private readonly IRepository _orderNoteRepository; private readonly IRepository _merchandiseReturnRepository; private readonly IRepository _merchandiseReturnNoteRepository; @@ -941,6 +944,14 @@ await dbContext.CreateIndex(_oidcSessionRepository, await dbContext.CreateIndex(_oidcSessionRepository, OrderBuilder.Create().Ascending(x => x.EndedUtc), "EndedUtc"); + //order approval + await dbContext.CreateIndex(_orderApprovalRepository, + OrderBuilder.Create().Ascending(x => x.CompanyOwnerId).Ascending(x => x.Status) + .Descending(x => x.CreatedOnUtc), "CompanyOwnerId_Status_CreatedOnUtc"); + await dbContext.CreateIndex(_orderApprovalRepository, + OrderBuilder.Create().Ascending(x => x.CustomerId).Ascending(x => x.StoreId) + .Ascending(x => x.Status), "CustomerId_StoreId_Status"); + //merchandise return await dbContext.CreateIndex(_merchandiseReturnRepository, OrderBuilder.Create().Ascending(x => x.ReturnNumber), "ReturnNumber", true); diff --git a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderApprovalFingerprintTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderApprovalFingerprintTests.cs new file mode 100644 index 0000000000..37cb803d2c --- /dev/null +++ b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderApprovalFingerprintTests.cs @@ -0,0 +1,136 @@ +using Grand.Business.Checkout.Services.Orders; +using Grand.Domain.Common; +using Grand.Domain.Orders; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Business.Checkout.Tests.Services.Orders; + +[TestClass] +public class OrderApprovalFingerprintTests +{ + private static ShoppingCartItem Item(string productId, int quantity = 1, + params (string Key, string Value)[] attributes) + { + var item = new ShoppingCartItem { + ProductId = productId, + Quantity = quantity, + ShoppingCartTypeId = ShoppingCartType.ShoppingCart + }; + foreach (var (key, value) in attributes) + item.Attributes.Add(new CustomAttribute { Key = key, Value = value }); + return item; + } + + [TestMethod] + public void Compute_SameItemsInOtherOrder_SameHash() + { + var first = OrderApprovalFingerprint.Compute([Item("a"), Item("b", 2), Item("a", 3, ("color", "red"))]); + var second = OrderApprovalFingerprint.Compute([Item("a", 3, ("color", "red")), Item("a"), Item("b", 2)]); + + Assert.AreEqual(first, second); + } + + [TestMethod] + public void Compute_AttributesInOtherOrder_SameHash() + { + var first = OrderApprovalFingerprint.Compute([Item("a", 1, ("color", "red"), ("size", "m"))]); + var second = OrderApprovalFingerprint.Compute([Item("a", 1, ("size", "m"), ("color", "red"))]); + + Assert.AreEqual(first, second); + } + + [TestMethod] + public void Compute_QuantityChanged_DifferentHash() + { + var before = OrderApprovalFingerprint.Compute([Item("a", 1)]); + var after = OrderApprovalFingerprint.Compute([Item("a", 2)]); + + Assert.AreNotEqual(before, after); + } + + [TestMethod] + public void Compute_AttributeValueChanged_DifferentHash() + { + var before = OrderApprovalFingerprint.Compute([Item("a", 1, ("color", "red"))]); + var after = OrderApprovalFingerprint.Compute([Item("a", 1, ("color", "blue"))]); + + Assert.AreNotEqual(before, after); + } + + [TestMethod] + public void Compute_AttributeMovedBetweenKeyAndValue_DifferentHash() + { + var before = OrderApprovalFingerprint.Compute([Item("a", 1, ("ab", "c"))]); + var after = OrderApprovalFingerprint.Compute([Item("a", 1, ("a", "bc"))]); + + Assert.AreNotEqual(before, after); + } + + [TestMethod] + public void Compute_RentalDatesChanged_DifferentHash() + { + var start = new DateTime(2026, 11, 1, 0, 0, 0, DateTimeKind.Utc); + var item = Item("a"); + item.RentalStartDateUtc = start; + item.RentalEndDateUtc = start.AddDays(2); + var before = OrderApprovalFingerprint.Compute([item]); + + item.RentalEndDateUtc = start.AddDays(3); + var after = OrderApprovalFingerprint.Compute([item]); + + Assert.AreNotEqual(before, after); + } + + [TestMethod] + public void Compute_EnteredPriceChanged_DifferentHash() + { + var item = Item("a"); + item.EnteredPrice = 10; + var before = OrderApprovalFingerprint.Compute([item]); + + item.EnteredPrice = 5; + var after = OrderApprovalFingerprint.Compute([item]); + + Assert.AreNotEqual(before, after); + } + + [TestMethod] + public void Compute_WarehouseChanged_DifferentHash() + { + var item = Item("a"); + item.WarehouseId = "w1"; + var before = OrderApprovalFingerprint.Compute([item]); + + item.WarehouseId = "w2"; + var after = OrderApprovalFingerprint.Compute([item]); + + Assert.AreNotEqual(before, after); + } + + [TestMethod] + public void Compute_WishlistItemsIgnored_SameHash() + { + var wishlist = Item("w"); + wishlist.ShoppingCartTypeId = ShoppingCartType.Wishlist; + + var withoutWishlist = OrderApprovalFingerprint.Compute([Item("a")]); + var withWishlist = OrderApprovalFingerprint.Compute([Item("a"), wishlist]); + + Assert.AreEqual(withoutWishlist, withWishlist); + } + + [TestMethod] + public void Compute_Called_ReturnsSha256Hex() + { + var hash = OrderApprovalFingerprint.Compute([Item("a")]); + + Assert.AreEqual(64, hash.Length); + Assert.IsTrue(hash.All(Uri.IsHexDigit)); + } + + [TestMethod] + public void Compute_EmptyCart_DifferentFromACart() + { + Assert.AreNotEqual(OrderApprovalFingerprint.Compute([]), OrderApprovalFingerprint.Compute([Item("a")])); + } +} diff --git a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderApprovalServiceTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderApprovalServiceTests.cs new file mode 100644 index 0000000000..7bb8b4c396 --- /dev/null +++ b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderApprovalServiceTests.cs @@ -0,0 +1,431 @@ +using Grand.Business.Checkout.Services.Orders; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Utilities.Checkout; +using Grand.Data; +using Grand.Data.Tests.MongoDb; +using Grand.Domain.Customers; +using Grand.Domain.Directory; +using Grand.Domain.Orders; +using Grand.Domain.Stores; +using Grand.Domain.Tax; +using Grand.Infrastructure.Events; +using Grand.Mediator; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Business.Checkout.Tests.Services.Orders; + +[TestClass] +public class OrderApprovalServiceTests +{ + private const string StoreId = "store1"; + private const string OwnerId = "owner1"; + private const string BuyerId = "buyer1"; + + private Mock _calculationServiceMock; + private CustomerSettings _customerSettings; + private Mock _mediatorMock; + private IRepository _repository; + private OrderApprovalService _service; + private double? _total; + + [TestInitialize] + public void Init() + { + _repository = new MongoDBRepositoryTest(); + _mediatorMock = new Mock(); + _customerSettings = new CustomerSettings(); + _total = 150; + _calculationServiceMock = new Mock(); + _calculationServiceMock + .Setup(x => x.GetShoppingCartTotal(It.IsAny>(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(() => new ShoppingCartTotal(_total, 0, [], [], 0, 0)); + + _service = new OrderApprovalService(_repository, _calculationServiceMock.Object, _customerSettings, + _mediatorMock.Object); + } + + private static Customer Buyer(double? limit = 100, SubAccountRole role = SubAccountRole.Buyer, + string ownerId = OwnerId) + { + return new Customer { + Id = BuyerId, OwnerId = ownerId, SubAccountRole = role, OrderApprovalLimit = limit + }; + } + + private static CalculationContext Context(Customer customer, string storeId = StoreId) + { + return new CalculationContext(customer, new Store { Id = storeId }, new Currency(), + TaxDisplayType.IncludingTax); + } + + private static List Cart(int quantity = 1) + { + return [ + new ShoppingCartItem { + ProductId = "p1", Quantity = quantity, StoreId = StoreId, + ShoppingCartTypeId = ShoppingCartType.ShoppingCart + } + ]; + } + + private async Task AddApproval(OrderApprovalStatus status, IList cart, + double amount, string customerId = BuyerId, string storeId = StoreId, string companyOwnerId = OwnerId, + DateTime? createdOnUtc = null) + { + var approval = new OrderApproval { + StoreId = storeId, + CompanyOwnerId = companyOwnerId, + CustomerId = customerId, + Status = status, + CartFingerprint = OrderApprovalFingerprint.Compute(cart), + Amount = amount + }; + await _repository.InsertAsync(approval); + + // the repository stamps CreatedOnUtc on insert; set the wanted time afterwards + approval.CreatedOnUtc = createdOnUtc ?? DateTime.UtcNow; + await _repository.UpdateAsync(approval); + return approval; + } + + #region Checkout state + + [TestMethod] + public async Task GetCheckoutState_NoOwner_NotRequired() + { + var customer = Buyer(100, ownerId: null); + + var state = await _service.GetCheckoutState(Cart(), Context(customer)); + + Assert.IsFalse(state.Required); + Assert.IsFalse(state.Blocked); + _calculationServiceMock.Verify(x => x.GetShoppingCartTotal(It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task GetCheckoutState_NoLimit_NotRequired() + { + var state = await _service.GetCheckoutState(Cart(), Context(Buyer(null))); + + Assert.IsFalse(state.Required); + Assert.IsFalse(state.Blocked); + } + + [TestMethod] + public async Task GetCheckoutState_Approver_NotRequired() + { + var state = await _service.GetCheckoutState(Cart(), Context(Buyer(100, SubAccountRole.Approver))); + + Assert.IsFalse(state.Required); + Assert.IsFalse(state.Blocked); + } + + [TestMethod] + public async Task GetCheckoutState_FeatureOff_NotRequired() + { + _customerSettings.HideSubAccountsTab = true; + + var state = await _service.GetCheckoutState(Cart(), Context(Buyer(100))); + + Assert.IsFalse(state.Required); + Assert.IsFalse(state.Blocked); + } + + [TestMethod] + public async Task GetCheckoutState_TotalUnknown_NotRequired() + { + _total = null; + + var state = await _service.GetCheckoutState(Cart(), Context(Buyer(100))); + + Assert.IsFalse(state.Required); + Assert.IsFalse(state.Blocked); + Assert.IsNull(state.Total); + Assert.AreEqual(100, state.Limit); + } + + [TestMethod] + public async Task GetCheckoutState_TotalAtLimit_NotRequired() + { + _total = 100; + + var state = await _service.GetCheckoutState(Cart(), Context(Buyer(100))); + + Assert.IsFalse(state.Required); + Assert.IsFalse(state.Blocked); + Assert.AreEqual(100, state.Total); + } + + [TestMethod] + public async Task GetCheckoutState_OverLimit_Blocked() + { + var state = await _service.GetCheckoutState(Cart(), Context(Buyer(100))); + + Assert.IsTrue(state.Required); + Assert.IsTrue(state.Blocked); + Assert.AreEqual(100, state.Limit); + Assert.AreEqual(150, state.Total); + Assert.IsNull(state.Active); + } + + [TestMethod] + public async Task GetCheckoutState_OverLimitWithPending_BlockedAndPendingIsActive() + { + var cart = Cart(); + var pending = await AddApproval(OrderApprovalStatus.Pending, cart, 150); + + var state = await _service.GetCheckoutState(cart, Context(Buyer(100))); + + Assert.IsTrue(state.Blocked); + Assert.AreEqual(pending.Id, state.Active?.Id); + } + + [TestMethod] + public async Task GetCheckoutState_ApprovedMatching_NotBlocked() + { + var cart = Cart(); + var approval = await AddApproval(OrderApprovalStatus.Approved, cart, 150); + + var state = await _service.GetCheckoutState(cart, Context(Buyer(100))); + + Assert.IsTrue(state.Required); + Assert.IsFalse(state.Blocked); + Assert.AreEqual(approval.Id, state.Active?.Id); + } + + [TestMethod] + public async Task GetCheckoutState_ApprovedButCartChanged_Blocked() + { + await AddApproval(OrderApprovalStatus.Approved, Cart(1), 150); + + var state = await _service.GetCheckoutState(Cart(2), Context(Buyer(100))); + + Assert.IsTrue(state.Blocked); + Assert.IsNull(state.Active); + } + + [TestMethod] + public async Task GetCheckoutState_ApprovedButTotalHigher_Blocked() + { + var cart = Cart(); + await AddApproval(OrderApprovalStatus.Approved, cart, 150); + _total = 150.01; + + var state = await _service.GetCheckoutState(cart, Context(Buyer(100))); + + Assert.IsTrue(state.Blocked); + } + + [TestMethod] + public async Task GetCheckoutState_ApprovedButTotalLower_NotBlocked() + { + var cart = Cart(); + await AddApproval(OrderApprovalStatus.Approved, cart, 150); + _total = 120; + + var state = await _service.GetCheckoutState(cart, Context(Buyer(100))); + + Assert.IsFalse(state.Blocked); + } + + [TestMethod] + public async Task GetCheckoutState_ApprovalOfOtherStore_Blocked() + { + var cart = Cart(); + await AddApproval(OrderApprovalStatus.Approved, cart, 150, storeId: "other-store"); + + var state = await _service.GetCheckoutState(cart, Context(Buyer(100))); + + Assert.IsTrue(state.Blocked); + } + + [TestMethod] + public async Task GetCheckoutState_ApprovalOfOtherCustomer_Blocked() + { + var cart = Cart(); + await AddApproval(OrderApprovalStatus.Approved, cart, 150, "another-buyer"); + + var state = await _service.GetCheckoutState(cart, Context(Buyer(100))); + + Assert.IsTrue(state.Blocked); + } + + [TestMethod] + [DataRow(OrderApprovalStatus.Rejected)] + [DataRow(OrderApprovalStatus.Withdrawn)] + [DataRow(OrderApprovalStatus.Used)] + public async Task GetCheckoutState_ApprovalNoLongerUsable_Blocked(OrderApprovalStatus status) + { + var cart = Cart(); + await AddApproval(status, cart, 150); + + var state = await _service.GetCheckoutState(cart, Context(Buyer(100))); + + Assert.IsTrue(state.Blocked); + Assert.IsNull(state.Active); + } + + #endregion + + #region Reads and writes + + [TestMethod] + public async Task GetById_OtherCompany_Null() + { + var approval = await AddApproval(OrderApprovalStatus.Pending, Cart(), 150); + + Assert.IsNull(await _service.GetById(approval.Id, "other-company")); + Assert.IsNull(await _service.GetById(approval.Id, null)); + Assert.IsNull(await _service.GetById(approval.Id, "")); + } + + [TestMethod] + public async Task GetById_OwnCompany_ReturnsApproval() + { + var approval = await AddApproval(OrderApprovalStatus.Pending, Cart(), 150); + + var result = await _service.GetById(approval.Id, OwnerId); + + Assert.AreEqual(approval.Id, result?.Id); + } + + [TestMethod] + public async Task GetActive_ReturnsNewestPendingOrApproved() + { + var now = DateTime.UtcNow; + await AddApproval(OrderApprovalStatus.Rejected, Cart(), 150, createdOnUtc: now); + await AddApproval(OrderApprovalStatus.Pending, Cart(), 150, createdOnUtc: now.AddHours(-2)); + var newest = await AddApproval(OrderApprovalStatus.Approved, Cart(), 150, createdOnUtc: now.AddHours(-1)); + await AddApproval(OrderApprovalStatus.Approved, Cart(), 150, "another-buyer", createdOnUtc: now); + await AddApproval(OrderApprovalStatus.Approved, Cart(), 150, storeId: "other-store", createdOnUtc: now); + + var result = await _service.GetActive(BuyerId, StoreId); + + Assert.AreEqual(newest.Id, result?.Id); + } + + [TestMethod] + public async Task GetActive_NothingActive_Null() + { + await AddApproval(OrderApprovalStatus.Used, Cart(), 150); + + Assert.IsNull(await _service.GetActive(BuyerId, StoreId)); + } + + [TestMethod] + public async Task GetForCompany_PendingFirst() + { + var now = DateTime.UtcNow; + var oldPending = await AddApproval(OrderApprovalStatus.Pending, Cart(), 150, createdOnUtc: now.AddDays(-3)); + var newApproved = await AddApproval(OrderApprovalStatus.Approved, Cart(), 150, createdOnUtc: now); + var oldApproved = await AddApproval(OrderApprovalStatus.Approved, Cart(), 150, createdOnUtc: now.AddDays(-1)); + var newPending = await AddApproval(OrderApprovalStatus.Pending, Cart(), 150, createdOnUtc: now.AddDays(-2)); + await AddApproval(OrderApprovalStatus.Pending, Cart(), 150, companyOwnerId: "other-company"); + await AddApproval(OrderApprovalStatus.Pending, Cart(), 150, storeId: "other-store"); + + var result = await _service.GetForCompany(OwnerId, StoreId, 0, 10); + + CollectionAssert.AreEqual( + new[] { newPending.Id, oldPending.Id, newApproved.Id, oldApproved.Id }, + result.Select(x => x.Id).ToArray()); + Assert.AreEqual(4, result.TotalCount); + } + + [TestMethod] + public async Task GetForCompany_Paged() + { + for (var i = 0; i < 5; i++) + await AddApproval(OrderApprovalStatus.Pending, Cart(), 150, createdOnUtc: DateTime.UtcNow.AddMinutes(-i)); + + var result = await _service.GetForCompany(OwnerId, StoreId, 1, 2); + + Assert.AreEqual(2, result.Count); + Assert.AreEqual(5, result.TotalCount); + } + + [TestMethod] + public async Task GetForCustomer_NewestFirstAndOnlyOwn() + { + var now = DateTime.UtcNow; + var older = await AddApproval(OrderApprovalStatus.Rejected, Cart(), 150, createdOnUtc: now.AddDays(-1)); + var newer = await AddApproval(OrderApprovalStatus.Pending, Cart(), 150, createdOnUtc: now); + await AddApproval(OrderApprovalStatus.Pending, Cart(), 150, "another-buyer"); + await AddApproval(OrderApprovalStatus.Pending, Cart(), 150, storeId: "other-store"); + + var result = await _service.GetForCustomer(BuyerId, StoreId, 0, 10); + + CollectionAssert.AreEqual(new[] { newer.Id, older.Id }, result.Select(x => x.Id).ToArray()); + } + + [TestMethod] + public async Task Insert_StoresAndPublishesEvent() + { + var approval = new OrderApproval { CustomerId = BuyerId, StoreId = StoreId, CompanyOwnerId = OwnerId }; + + await _service.Insert(approval); + + Assert.IsNotNull(await _repository.GetByIdAsync(approval.Id)); + _mediatorMock.Verify(x => x.Publish(It.IsAny>(), default), Times.Once); + } + + [TestMethod] + public async Task Update_StoresAndPublishesEvent() + { + var approval = await AddApproval(OrderApprovalStatus.Pending, Cart(), 150); + approval.Status = OrderApprovalStatus.Approved; + + await _service.Update(approval); + + var stored = await _repository.GetByIdAsync(approval.Id); + Assert.AreEqual(OrderApprovalStatus.Approved, stored.Status); + _mediatorMock.Verify(x => x.Publish(It.IsAny>(), default), Times.Once); + } + + [TestMethod] + public async Task Insert_Null_Throws() + { + await Assert.ThrowsExactlyAsync(async () => await _service.Insert(null)); + } + + #endregion + + #region Rules + + [TestMethod] + public void CompanyId_Owner_IsOwnId() + { + Assert.AreEqual("c1", OrderApprovalRules.CompanyId(new Customer { Id = "c1" })); + } + + [TestMethod] + public void CompanyId_SubAccount_IsOwnerId() + { + Assert.AreEqual(OwnerId, OrderApprovalRules.CompanyId(new Customer { Id = "c1", OwnerId = OwnerId })); + } + + [TestMethod] + public void CanApprove_OwnerAndApproverOnly() + { + Assert.IsTrue(OrderApprovalRules.CanApprove(new Customer())); + Assert.IsTrue(OrderApprovalRules.CanApprove(new Customer + { OwnerId = OwnerId, SubAccountRole = SubAccountRole.Approver })); + Assert.IsFalse(OrderApprovalRules.CanApprove(new Customer + { OwnerId = OwnerId, SubAccountRole = SubAccountRole.Buyer })); + } + + [TestMethod] + public void CheckoutState_Blocked_OnlyWhenRequiredAndNotApproved() + { + Assert.IsFalse(new OrderApprovalCheckoutState(false, null, null, null).Blocked); + Assert.IsTrue(new OrderApprovalCheckoutState(true, 1, 2, null).Blocked); + Assert.IsTrue(new OrderApprovalCheckoutState(true, 1, 2, + new OrderApproval { Status = OrderApprovalStatus.Pending }).Blocked); + Assert.IsFalse(new OrderApprovalCheckoutState(true, 1, 2, + new OrderApproval { Status = OrderApprovalStatus.Approved }).Blocked); + } + + #endregion +} From 4592d5de9d132d7b325039265293d2823ff13f18 Mon Sep 17 00:00:00 2001 From: support Date: Sun, 25 Oct 2026 07:51:24 +0100 Subject: [PATCH 09/28] Block over-limit company orders without an approval --- .../Orders/OrderPlacedOrderApprovalHandler.cs | 30 ++++++ .../Services/Orders/ShoppingCartValidator.cs | 7 ++ .../ShoppingCartApprovalValidator.cs | 39 ++++++++ .../OrderPlacedOrderApprovalHandlerTests.cs | 70 ++++++++++++++ .../Orders/ShoppingCartValidatorTests.cs | 21 +++++ .../ShoppingCartApprovalValidatorTests.cs | 94 +++++++++++++++++++ 6 files changed, 261 insertions(+) create mode 100644 src/Business/Grand.Business.Checkout/Events/Orders/OrderPlacedOrderApprovalHandler.cs create mode 100644 src/Business/Grand.Business.Checkout/Validators/ShoppingCartApprovalValidator.cs create mode 100644 src/Tests/Grand.Business.Checkout.Tests/Events/Orders/OrderPlacedOrderApprovalHandlerTests.cs create mode 100644 src/Tests/Grand.Business.Checkout.Tests/Validators/ShoppingCartApprovalValidatorTests.cs diff --git a/src/Business/Grand.Business.Checkout/Events/Orders/OrderPlacedOrderApprovalHandler.cs b/src/Business/Grand.Business.Checkout/Events/Orders/OrderPlacedOrderApprovalHandler.cs new file mode 100644 index 0000000000..d6338da3a2 --- /dev/null +++ b/src/Business/Grand.Business.Checkout/Events/Orders/OrderPlacedOrderApprovalHandler.cs @@ -0,0 +1,30 @@ +using Grand.Business.Core.Events.Checkout.Orders; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Domain.Orders; +using Grand.Mediator; + +namespace Grand.Business.Checkout.Events.Orders; + +/// +/// Marks the approval that let the order through as used, so it cannot be spent on another order +/// +public class OrderPlacedOrderApprovalHandler : INotificationHandler +{ + private readonly IOrderApprovalService _orderApprovalService; + + public OrderPlacedOrderApprovalHandler(IOrderApprovalService orderApprovalService) + { + _orderApprovalService = orderApprovalService; + } + + public async Task Handle(OrderPlacedEvent notification, CancellationToken cancellationToken) + { + var order = notification.Order; + var approval = await _orderApprovalService.GetActive(order.CustomerId, order.StoreId); + if (approval is not { Status: OrderApprovalStatus.Approved }) return; + + approval.Status = OrderApprovalStatus.Used; + approval.OrderId = order.Id; + await _orderApprovalService.Update(approval); + } +} diff --git a/src/Business/Grand.Business.Checkout/Services/Orders/ShoppingCartValidator.cs b/src/Business/Grand.Business.Checkout/Services/Orders/ShoppingCartValidator.cs index 24660dded2..54b3d11d2b 100644 --- a/src/Business/Grand.Business.Checkout/Services/Orders/ShoppingCartValidator.cs +++ b/src/Business/Grand.Business.Checkout/Services/Orders/ShoppingCartValidator.cs @@ -164,6 +164,13 @@ public virtual async Task> GetShoppingCartWarnings(IList x.ErrorMessage)); + + //over-limit company orders need an approval + var resultApproval = await _validatorFactory + .GetValidator().ValidateAsync( + new ShoppingCartApprovalValidatorRecord(shoppingCart, context, language)); + if (!resultApproval.IsValid) + warnings.AddRange(resultApproval.Errors.Select(x => x.ErrorMessage)); } //event notification diff --git a/src/Business/Grand.Business.Checkout/Validators/ShoppingCartApprovalValidator.cs b/src/Business/Grand.Business.Checkout/Validators/ShoppingCartApprovalValidator.cs new file mode 100644 index 0000000000..d1d867139b --- /dev/null +++ b/src/Business/Grand.Business.Checkout/Validators/ShoppingCartApprovalValidator.cs @@ -0,0 +1,39 @@ +using FluentValidation; +using Grand.Business.Core.Interfaces.Catalog.Prices; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Utilities.Checkout; +using Grand.Domain.Localization; +using Grand.Domain.Orders; + +namespace Grand.Business.Checkout.Validators; + +public record ShoppingCartApprovalValidatorRecord( + IList Cart, + CalculationContext Context, + Language Language); + +/// +/// Refuses a cart of a company sub-account whose total exceeds the order limit until an approval covers it +/// +public class ShoppingCartApprovalValidator : AbstractValidator +{ + public ShoppingCartApprovalValidator( + IOrderApprovalService orderApprovalService, + ICurrencyService currencyService, + IPriceFormatter priceFormatter, + ITranslationService translationService) + { + RuleFor(x => x).CustomAsync(async (value, context, _) => + { + var state = await orderApprovalService.GetCheckoutState(value.Cart, value.Context); + if (!state.Blocked) return; + + var limit = await currencyService.ConvertFromPrimaryStoreCurrency(state.Limit ?? 0, value.Context.Currency); + context.AddFailure(string.Format( + translationService.GetResource("Checkout.OrderApproval.Required", value.Language.Id), + priceFormatter.FormatPrice(limit, value.Context.Currency))); + }); + } +} diff --git a/src/Tests/Grand.Business.Checkout.Tests/Events/Orders/OrderPlacedOrderApprovalHandlerTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Events/Orders/OrderPlacedOrderApprovalHandlerTests.cs new file mode 100644 index 0000000000..a3a3a6efa2 --- /dev/null +++ b/src/Tests/Grand.Business.Checkout.Tests/Events/Orders/OrderPlacedOrderApprovalHandlerTests.cs @@ -0,0 +1,70 @@ +using Grand.Business.Checkout.Events.Orders; +using Grand.Business.Core.Events.Checkout.Orders; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Domain.Orders; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Business.Checkout.Tests.Events.Orders; + +[TestClass] +public class OrderPlacedOrderApprovalHandlerTests +{ + private OrderPlacedOrderApprovalHandler _handler; + private Mock _orderApprovalServiceMock; + + [TestInitialize] + public void Init() + { + _orderApprovalServiceMock = new Mock(); + _handler = new OrderPlacedOrderApprovalHandler(_orderApprovalServiceMock.Object); + } + + [TestMethod] + public async Task Handle_ActiveApproved_MarkedUsedWithOrderId() + { + //Arrange + var approval = new OrderApproval { Status = OrderApprovalStatus.Approved }; + var order = new Order { Id = "order1", CustomerId = "customer1", StoreId = "store1" }; + _orderApprovalServiceMock.Setup(x => x.GetActive("customer1", "store1")).ReturnsAsync(approval); + + //Act + await _handler.Handle(new OrderPlacedEvent(order), CancellationToken.None); + + //Assert + Assert.AreEqual(OrderApprovalStatus.Used, approval.Status); + Assert.AreEqual("order1", approval.OrderId); + _orderApprovalServiceMock.Verify(x => x.Update(approval), Times.Once); + } + + [TestMethod] + public async Task Handle_OnlyPending_Untouched() + { + //Arrange + var approval = new OrderApproval { Status = OrderApprovalStatus.Pending }; + var order = new Order { Id = "order1", CustomerId = "customer1", StoreId = "store1" }; + _orderApprovalServiceMock.Setup(x => x.GetActive("customer1", "store1")).ReturnsAsync(approval); + + //Act + await _handler.Handle(new OrderPlacedEvent(order), CancellationToken.None); + + //Assert + Assert.AreEqual(OrderApprovalStatus.Pending, approval.Status); + Assert.IsNull(approval.OrderId); + _orderApprovalServiceMock.Verify(x => x.Update(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Handle_NoActive_NoWrite() + { + //Arrange + var order = new Order { Id = "order1", CustomerId = "customer1", StoreId = "store1" }; + _orderApprovalServiceMock.Setup(x => x.GetActive("customer1", "store1")).ReturnsAsync((OrderApproval)null); + + //Act + await _handler.Handle(new OrderPlacedEvent(order), CancellationToken.None); + + //Assert + _orderApprovalServiceMock.Verify(x => x.Update(It.IsAny()), Times.Never); + } +} diff --git a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/ShoppingCartValidatorTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/ShoppingCartValidatorTests.cs index d9f2d0ac75..49d46a6418 100644 --- a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/ShoppingCartValidatorTests.cs +++ b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/ShoppingCartValidatorTests.cs @@ -36,6 +36,7 @@ public void Init() Capture(); Capture(); Capture(); + Capture(); Capture(); _mediatorMock = new Mock(); _validator = new ShoppingCartValidator(_mediatorMock.Object, _validatorFactoryMock.Object); @@ -73,6 +74,26 @@ public async Task GetShoppingCartWarnings_ValidatesForTheContextCustomerAndStore var amount = _records.OfType().Single(); Assert.AreSame(context, amount.Context); Assert.AreSame(Language, amount.Language); + var approval = _records.OfType().Single(); + Assert.AreSame(cart, approval.Cart); + Assert.AreSame(context, approval.Context); + Assert.AreSame(Language, approval.Language); + } + + [TestMethod] + public async Task GetShoppingCartWarnings_ValidateAmountFalse_DoesNotRunApproval() + { + //Arrange + var context = new CalculationContext(new Customer(), new Store { Id = "store" }, + new Currency { CurrencyCode = "EUR" }, TaxDisplayType.IncludingTax); + + //Act + await _validator.GetShoppingCartWarnings(new List(), new List(), context, + Language, true, false); + + //Assert + Assert.AreEqual(0, _records.OfType().Count()); + Assert.AreEqual(0, _records.OfType().Count()); } [TestMethod] diff --git a/src/Tests/Grand.Business.Checkout.Tests/Validators/ShoppingCartApprovalValidatorTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Validators/ShoppingCartApprovalValidatorTests.cs new file mode 100644 index 0000000000..39dbaa445b --- /dev/null +++ b/src/Tests/Grand.Business.Checkout.Tests/Validators/ShoppingCartApprovalValidatorTests.cs @@ -0,0 +1,94 @@ +using Grand.Business.Checkout.Validators; +using Grand.Business.Core.Interfaces.Catalog.Prices; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Utilities.Checkout; +using Grand.Domain.Customers; +using Grand.Domain.Directory; +using Grand.Domain.Localization; +using Grand.Domain.Orders; +using Grand.Domain.Stores; +using Grand.Domain.Tax; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Business.Checkout.Tests.Validators; + +[TestClass] +public class ShoppingCartApprovalValidatorTests +{ + private static readonly Language Language = new() { Id = "lang1" }; + private Mock _currencyServiceMock; + private CalculationContext _context; + private Mock _orderApprovalServiceMock; + private Mock _priceFormatterMock; + private Mock _translationServiceMock; + private ShoppingCartApprovalValidator _validator; + + [TestInitialize] + public void Init() + { + _orderApprovalServiceMock = new Mock(); + _currencyServiceMock = new Mock(); + _priceFormatterMock = new Mock(); + _translationServiceMock = new Mock(); + _context = new CalculationContext(new Customer(), new Store { Id = "store" }, + new Currency { CurrencyCode = "EUR" }, TaxDisplayType.IncludingTax); + _validator = new ShoppingCartApprovalValidator(_orderApprovalServiceMock.Object, _currencyServiceMock.Object, + _priceFormatterMock.Object, _translationServiceMock.Object); + } + + [TestMethod] + public async Task Validate_Blocked_FailsWithFormattedLimit() + { + //Arrange + var cart = new List { new() }; + _orderApprovalServiceMock.Setup(x => x.GetCheckoutState(cart, _context)) + .ReturnsAsync(new OrderApprovalCheckoutState(true, 100, 150, null)); + _currencyServiceMock.Setup(x => x.ConvertFromPrimaryStoreCurrency(100, _context.Currency)).ReturnsAsync(110); + _priceFormatterMock.Setup(x => x.FormatPrice(110, _context.Currency)).Returns("110.00 EUR"); + _translationServiceMock.Setup(x => x.GetResource("Checkout.OrderApproval.Required", Language.Id, It.IsAny(), It.IsAny())) + .Returns("An approval is required above {0}."); + + //Act + var result = await _validator.ValidateAsync(new ShoppingCartApprovalValidatorRecord(cart, _context, Language)); + + //Assert + Assert.IsFalse(result.IsValid); + Assert.AreEqual("An approval is required above 110.00 EUR.", result.Errors.Single().ErrorMessage); + } + + [TestMethod] + public async Task Validate_NotBlocked_Passes() + { + //Arrange + var cart = new List { new() }; + _orderApprovalServiceMock.Setup(x => x.GetCheckoutState(cart, _context)) + .ReturnsAsync(new OrderApprovalCheckoutState(false, null, 50, null)); + + //Act + var result = await _validator.ValidateAsync(new ShoppingCartApprovalValidatorRecord(cart, _context, Language)); + + //Assert + Assert.IsTrue(result.IsValid); + _currencyServiceMock.Verify( + x => x.ConvertFromPrimaryStoreCurrency(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Validate_RequiredAndApproved_Passes() + { + //Arrange + var cart = new List { new() }; + _orderApprovalServiceMock.Setup(x => x.GetCheckoutState(cart, _context)) + .ReturnsAsync(new OrderApprovalCheckoutState(true, 100, 150, + new OrderApproval { Status = OrderApprovalStatus.Approved })); + + //Act + var result = await _validator.ValidateAsync(new ShoppingCartApprovalValidatorRecord(cart, _context, Language)); + + //Assert + Assert.IsTrue(result.IsValid); + } +} From 3681c06d1dc4307dad944bbac3f27a542ef92703 Mon Sep 17 00:00:00 2001 From: support Date: Sun, 25 Oct 2026 08:05:29 +0100 Subject: [PATCH 10/28] Notify approvers and buyers about order approvals --- .../ICustomerMessageProviderService.cs | 30 ++++ .../GetOrderApprovalTokensCommandHandler.cs | 69 ++++++++ .../Tokens/GetOrderApprovalTokensCommand.cs | 17 ++ .../DotLiquidDrops/LiquidObjectBuilder.cs | 14 ++ .../DotLiquidDrops/LiquidOrderApproval.cs | 47 +++++ .../CustomerMessageProviderService.cs | 68 ++++++++ .../Services/MessageTemplateNames.cs | 3 + .../Services/MessageTokenProvider.cs | 1 + .../Grand.Domain/Messages/LiquidObject.cs | 2 + .../MessageTemplateSeed.OrderApproval.cs | 49 ++++++ .../MessageTemplates/MessageTemplateSeed.cs | 1 + ...tOrderApprovalTokensCommandHandlerTests.cs | 116 +++++++++++++ ...essageProviderServiceOrderApprovalTests.cs | 162 ++++++++++++++++++ .../Installer/MessageTemplateSeedTests.cs | 8 +- .../OrderApprovalTemplateSeedTests.cs | 68 ++++++++ 15 files changed, 652 insertions(+), 3 deletions(-) create mode 100644 src/Business/Grand.Business.Messages/Commands/Handlers/Messages/GetOrderApprovalTokensCommandHandler.cs create mode 100644 src/Business/Grand.Business.Messages/Commands/Tokens/GetOrderApprovalTokensCommand.cs create mode 100644 src/Business/Grand.Business.Messages/DotLiquidDrops/LiquidOrderApproval.cs create mode 100644 src/Modules/Grand.Module.Installer/Services/MessageTemplates/MessageTemplateSeed.OrderApproval.cs create mode 100644 src/Tests/Grand.Business.Messages.Tests/Commands/GetOrderApprovalTokensCommandHandlerTests.cs create mode 100644 src/Tests/Grand.Business.Messages.Tests/Services/CustomerMessageProviderServiceOrderApprovalTests.cs create mode 100644 src/Tests/Grand.Modules.Tests/Installer/OrderApprovalTemplateSeedTests.cs diff --git a/src/Business/Grand.Business.Core/Interfaces/Messages/ICustomerMessageProviderService.cs b/src/Business/Grand.Business.Core/Interfaces/Messages/ICustomerMessageProviderService.cs index edb8d64102..809eeccf17 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Messages/ICustomerMessageProviderService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Messages/ICustomerMessageProviderService.cs @@ -1,5 +1,6 @@ using Grand.Domain.Common; using Grand.Domain.Customers; +using Grand.Domain.Orders; using Grand.Domain.Stores; namespace Grand.Business.Core.Interfaces.Messages; @@ -110,4 +111,33 @@ Task SendCustomerEmailTokenValidationMessage(Customer customer, Store store Task SendContactUsMessage(Customer customer, Store store, string languageId, string senderEmail, string senderName, string subject, string body, string attrInfo, IList customAttributes, string ipaddress, DomainHost host = null); + + /// + /// Sends an 'Order approval requested' message to the approver + /// + /// Order approval + /// Customer who asked for the approval + /// Customer who decides; the recipient + /// Store + /// Message language identifier + /// + /// Host of the storefront request; links use it when it is one of the store's domains, else the primary domain + /// + /// Queued email identifier + Task SendOrderApprovalRequestedMessage(OrderApproval approval, Customer buyer, Customer approver, + Store store, string languageId, DomainHost host = null); + + /// + /// Sends an 'Order approval decided' message to the buyer + /// + /// Order approval + /// Customer who asked for the approval; the recipient + /// Store + /// Message language identifier + /// + /// Host of the storefront request; links use it when it is one of the store's domains, else the primary domain + /// + /// Queued email identifier + Task SendOrderApprovalDecidedMessage(OrderApproval approval, Customer buyer, Store store, + string languageId, DomainHost host = null); } diff --git a/src/Business/Grand.Business.Messages/Commands/Handlers/Messages/GetOrderApprovalTokensCommandHandler.cs b/src/Business/Grand.Business.Messages/Commands/Handlers/Messages/GetOrderApprovalTokensCommandHandler.cs new file mode 100644 index 0000000000..91b5992322 --- /dev/null +++ b/src/Business/Grand.Business.Messages/Commands/Handlers/Messages/GetOrderApprovalTokensCommandHandler.cs @@ -0,0 +1,69 @@ +using Grand.Business.Messages.Commands.Tokens; +using Grand.Business.Messages.DotLiquidDrops; +using Grand.Domain.Customers; +using Grand.Domain.Orders; +using Grand.Mediator; +using System.Net; +using System.Text; + +namespace Grand.Business.Messages.Commands.Handlers.Messages; + +public class GetOrderApprovalTokensCommandHandler : IRequestHandler +{ + public Task Handle(GetOrderApprovalTokensCommand request, CancellationToken cancellationToken) + { + var approval = request.Approval; + var url = (request.Host?.Url ?? request.Store.Url).Trim('/'); + + var liquidOrderApproval = new LiquidOrderApproval { + BuyerName = request.Buyer.GetFullName(), + BuyerEmail = request.Buyer.Email, + Status = approval.Status.ToString(), + Total = approval.Summary?.Total, + ItemsHtml = ItemsHtml(approval.Items), + DecisionComment = Encode(approval.DecisionComment), + Url = $"{url}/account/orderapproval?id={WebUtility.UrlEncode(approval.Id)}", + CartUrl = $"{url}/cart" + }; + return Task.FromResult(liquidOrderApproval); + } + + private static string Encode(string value) + { + return WebUtility.HtmlEncode(value ?? string.Empty); + } + + /// + /// Every value in the table was typed or formatted outside of the template, so it is encoded here + /// + private static string ItemsHtml(IEnumerable items) + { + var sb = new StringBuilder(); + sb.Append(""); + sb.Append(""); + sb.Append(""); + sb.Append(""); + sb.Append(""); + sb.Append(""); + + foreach (var item in items ?? []) + { + sb.Append(""); + sb.Append(""); + sb.Append(""); + sb.Append(""); + sb.Append(""); + } + + sb.Append("
NamePriceQuantityTotal
").Append(Encode(item.ProductName)); + if (!string.IsNullOrEmpty(item.AttributeDescription)) + sb.Append("
").Append(Encode(item.AttributeDescription)); + if (!string.IsNullOrEmpty(item.Sku)) + sb.Append("
Sku: ").Append(Encode(item.Sku)); + sb.Append("
").Append(Encode(item.UnitPrice)) + .Append("").Append(item.Quantity) + .Append("").Append(Encode(item.LineTotal)) + .Append("
"); + return sb.ToString(); + } +} diff --git a/src/Business/Grand.Business.Messages/Commands/Tokens/GetOrderApprovalTokensCommand.cs b/src/Business/Grand.Business.Messages/Commands/Tokens/GetOrderApprovalTokensCommand.cs new file mode 100644 index 0000000000..18bace2f59 --- /dev/null +++ b/src/Business/Grand.Business.Messages/Commands/Tokens/GetOrderApprovalTokensCommand.cs @@ -0,0 +1,17 @@ +using Grand.Business.Messages.DotLiquidDrops; +using Grand.Domain.Customers; +using Grand.Domain.Localization; +using Grand.Domain.Orders; +using Grand.Domain.Stores; +using Grand.Mediator; + +namespace Grand.Business.Messages.Commands.Tokens; + +public class GetOrderApprovalTokensCommand : IRequest +{ + public OrderApproval Approval { get; set; } + public Customer Buyer { get; set; } + public Store Store { get; set; } + public DomainHost Host { get; set; } + public Language Language { get; set; } +} diff --git a/src/Business/Grand.Business.Messages/DotLiquidDrops/LiquidObjectBuilder.cs b/src/Business/Grand.Business.Messages/DotLiquidDrops/LiquidObjectBuilder.cs index 33b8a94571..3e25a75a72 100644 --- a/src/Business/Grand.Business.Messages/DotLiquidDrops/LiquidObjectBuilder.cs +++ b/src/Business/Grand.Business.Messages/DotLiquidDrops/LiquidObjectBuilder.cs @@ -68,6 +68,20 @@ public LiquidObjectBuilder AddOrderTokens(Order order, Customer customer, Store return this; } + public LiquidObjectBuilder AddOrderApprovalTokens(OrderApproval approval, Customer buyer, Store store, + DomainHost host, Language language) + { + _chain.Add(async liquidObject => + { + var liquidOrderApproval = await _mediator.Send(new GetOrderApprovalTokensCommand { + Approval = approval, Buyer = buyer, Store = store, Host = host, Language = language + }); + liquidObject.OrderApproval = liquidOrderApproval; + await _mediator.EntityTokensAdded(approval, liquidOrderApproval, liquidObject); + }); + return this; + } + public LiquidObjectBuilder AddShipmentTokens(Shipment shipment, Order order, Store store, DomainHost host, Language language) { diff --git a/src/Business/Grand.Business.Messages/DotLiquidDrops/LiquidOrderApproval.cs b/src/Business/Grand.Business.Messages/DotLiquidDrops/LiquidOrderApproval.cs new file mode 100644 index 0000000000..2947ece259 --- /dev/null +++ b/src/Business/Grand.Business.Messages/DotLiquidDrops/LiquidOrderApproval.cs @@ -0,0 +1,47 @@ +using DotLiquid; + +namespace Grand.Business.Messages.DotLiquidDrops; + +/// +/// Tokens of an order approval request. The command handler fills every value, so the getters never touch a service. +/// +public class LiquidOrderApproval : Drop +{ + public LiquidOrderApproval() + { + AdditionalTokens = new Dictionary(); + } + + public string BuyerName { get; set; } + + public string BuyerEmail { get; set; } + + public string Status { get; set; } + + /// + /// The order total, already formatted in the buyer's currency + /// + public string Total { get; set; } + + /// + /// The cart lines as an HTML table; every value is HTML-encoded + /// + public string ItemsHtml { get; set; } + + /// + /// The approver's comment; HTML-encoded + /// + public string DecisionComment { get; set; } + + /// + /// Absolute link to the approval request + /// + public string Url { get; set; } + + /// + /// Absolute link to the buyer's cart + /// + public string CartUrl { get; set; } + + public IDictionary AdditionalTokens { get; set; } +} diff --git a/src/Business/Grand.Business.Messages/Services/CustomerMessageProviderService.cs b/src/Business/Grand.Business.Messages/Services/CustomerMessageProviderService.cs index 51d4160b25..7a15f39f0b 100644 --- a/src/Business/Grand.Business.Messages/Services/CustomerMessageProviderService.cs +++ b/src/Business/Grand.Business.Messages/Services/CustomerMessageProviderService.cs @@ -5,6 +5,7 @@ using Grand.Domain.Common; using Grand.Domain.Customers; using Grand.Domain.Messages; +using Grand.Domain.Orders; using Grand.Domain.Stores; using Grand.Mediator; using Grand.SharedKernel.Extensions; @@ -83,6 +84,73 @@ protected virtual async Task SendCustomerMessage(Customer customer, Store s toEmail, toName, reference: Reference.Customer, objectId: customer.Id); } + /// + /// Send an order approval message; the recipient also provides the customer tokens + /// + protected virtual async Task SendOrderApprovalMessage(OrderApproval approval, Customer buyer, + Customer recipient, Store store, string languageId, string templateName, DomainHost host) + { + ArgumentNullException.ThrowIfNull(approval); + ArgumentNullException.ThrowIfNull(buyer); + ArgumentNullException.ThrowIfNull(recipient); + + var language = await _messageLookupService.EnsureLanguageIsActive(languageId, store.Id); + + var messageTemplate = await _messageLookupService.GetMessageTemplate(templateName, store.Id); + if (messageTemplate == null) + return 0; + + var emailAccount = await _messageLookupService.GetEmailAccountOfMessageTemplate(messageTemplate, language.Id); + + host = store.LinkHost(host); + var builder = new LiquidObjectBuilder(_mediator); + builder.AddStoreTokens(store, language, emailAccount) + .AddCustomerTokens(recipient, store, host, language) + .AddOrderApprovalTokens(approval, buyer, store, host, language); + + var liquidObject = await builder.BuildAsync(); + await _mediator.MessageTokensAdded(messageTemplate, liquidObject); + + return await _messageProviderService.SendNotification(messageTemplate, emailAccount, + languageId, liquidObject, + recipient.Email, recipient.GetFullName(), reference: Reference.Customer, objectId: buyer.Id); + } + + /// + /// Sends an 'Order approval requested' message to the approver + /// + /// Order approval + /// Customer who asked for the approval + /// Customer who decides; the recipient + /// Store + /// Message language identifier + /// + /// Host of the storefront request; links use it when it is one of the store's domains, else the primary domain + /// + public virtual async Task SendOrderApprovalRequestedMessage(OrderApproval approval, Customer buyer, + Customer approver, Store store, string languageId, DomainHost host = null) + { + return await SendOrderApprovalMessage(approval, buyer, approver, store, languageId, + MessageTemplateNames.OrderApprovalRequested, host); + } + + /// + /// Sends an 'Order approval decided' message to the buyer + /// + /// Order approval + /// Customer who asked for the approval; the recipient + /// Store + /// Message language identifier + /// + /// Host of the storefront request; links use it when it is one of the store's domains, else the primary domain + /// + public virtual async Task SendOrderApprovalDecidedMessage(OrderApproval approval, Customer buyer, + Store store, string languageId, DomainHost host = null) + { + return await SendOrderApprovalMessage(approval, buyer, buyer, store, languageId, + MessageTemplateNames.OrderApprovalDecided, host); + } + /// /// Sends 'New customer' notification message to a store owner /// diff --git a/src/Business/Grand.Business.Messages/Services/MessageTemplateNames.cs b/src/Business/Grand.Business.Messages/Services/MessageTemplateNames.cs index 6b34d0837a..73fa600acc 100644 --- a/src/Business/Grand.Business.Messages/Services/MessageTemplateNames.cs +++ b/src/Business/Grand.Business.Messages/Services/MessageTemplateNames.cs @@ -9,6 +9,9 @@ public static class MessageTemplateNames public const string CustomerNewCustomerNote = "Customer.NewCustomerNote"; public const string CustomerEmailTokenValidationMessage = "Customer.EmailTokenValidationMessage"; + public const string OrderApprovalRequested = "OrderApproval.Requested"; + public const string OrderApprovalDecided = "OrderApproval.Decided"; + public const string SendOrderPlacedStoreOwnerMessage = "OrderPlaced.StoreOwnerNotification"; public const string SendOrderPaidStoreOwnerMessage = "OrderPaid.StoreOwnerNotification"; public const string SendOrderCancelledStoreOwnerMessage = "OrderCancelled.StoreOwnerNotification"; diff --git a/src/Business/Grand.Business.Messages/Services/MessageTokenProvider.cs b/src/Business/Grand.Business.Messages/Services/MessageTokenProvider.cs index 8a9b117c63..6cd8f43062 100644 --- a/src/Business/Grand.Business.Messages/Services/MessageTokenProvider.cs +++ b/src/Business/Grand.Business.Messages/Services/MessageTokenProvider.cs @@ -37,6 +37,7 @@ public virtual string[] GetListOfAllowedTokens() typeof(LiquidNewsComment), typeof(LiquidNewsLetterSubscription), typeof(LiquidOrder), + typeof(LiquidOrderApproval), typeof(LiquidOrderItem), typeof(LiquidProduct), typeof(LiquidProductReview), diff --git a/src/Core/Grand.Domain/Messages/LiquidObject.cs b/src/Core/Grand.Domain/Messages/LiquidObject.cs index ae82f8dc69..0c557c67df 100644 --- a/src/Core/Grand.Domain/Messages/LiquidObject.cs +++ b/src/Core/Grand.Domain/Messages/LiquidObject.cs @@ -27,6 +27,8 @@ public class LiquidObject public Drop Order { get; set; } + public Drop OrderApproval { get; set; } + public Drop Product { get; set; } public Drop ProductReview { get; set; } diff --git a/src/Modules/Grand.Module.Installer/Services/MessageTemplates/MessageTemplateSeed.OrderApproval.cs b/src/Modules/Grand.Module.Installer/Services/MessageTemplates/MessageTemplateSeed.OrderApproval.cs new file mode 100644 index 0000000000..a86f8afa3e --- /dev/null +++ b/src/Modules/Grand.Module.Installer/Services/MessageTemplates/MessageTemplateSeed.OrderApproval.cs @@ -0,0 +1,49 @@ +using Grand.Domain.Messages; + +namespace Grand.Module.Installer.Services.MessageTemplates; + +public static partial class MessageTemplateSeed +{ + private static IEnumerable OrderApprovalTemplates(string ea) + { + return new List { + new() { + Name = "OrderApproval.Requested", + Subject = "{{Store.Name}} — Order approval requested by {{OrderApproval.BuyerName}}", + Body = Layout( + "An order is waiting for your approval.", + "Order approval requested", + @" +

Hello {{Customer.FullName | Escape}},

+

{{OrderApproval.BuyerName | Escape}} ({{OrderApproval.BuyerEmail | Escape}}) asked you to approve an order at {{Store.Name}}. It will not be placed until you decide.

" + + Button("{{OrderApproval.Url}}", "Review the order") + @" +

The order contains:

" + + "{{OrderApproval.ItemsHtml}}" + @" +

Order total: {{OrderApproval.Total}}

"), + IsActive = true, + EmailAccountId = ea + }, + new() { + Name = "OrderApproval.Decided", + Subject = "{{Store.Name}} — Your order approval request: {{OrderApproval.Status}}", + Body = Layout( + "Your order approval request has been decided.", + "Order approval {{OrderApproval.Status}}", + @" +

Hello {{Customer.FullName | Escape}},

+

Your request to approve an order at {{Store.Name}} has been decided. Status: {{OrderApproval.Status}}.

+ {% if OrderApproval.DecisionComment != null and OrderApproval.DecisionComment != '' %} +

Comment from the approver:

+

{{OrderApproval.DecisionComment}}

+ {% endif %}" + + Button("{{OrderApproval.CartUrl}}", "Go to your cart") + @" +

The order contains:

" + + "{{OrderApproval.ItemsHtml}}" + @" +

Order total: {{OrderApproval.Total}}

+

You can follow the request at {{OrderApproval.Url}}.

"), + IsActive = true, + EmailAccountId = ea + } + }; + } +} diff --git a/src/Modules/Grand.Module.Installer/Services/MessageTemplates/MessageTemplateSeed.cs b/src/Modules/Grand.Module.Installer/Services/MessageTemplates/MessageTemplateSeed.cs index 02fec596bf..54ebfc9494 100644 --- a/src/Modules/Grand.Module.Installer/Services/MessageTemplates/MessageTemplateSeed.cs +++ b/src/Modules/Grand.Module.Installer/Services/MessageTemplates/MessageTemplateSeed.cs @@ -13,6 +13,7 @@ public static List Build(string emailAccountId) templates.AddRange(AuctionTemplates(emailAccountId)); templates.AddRange(OwnerNotificationTemplates(emailAccountId)); templates.AddRange(ServiceTemplates(emailAccountId)); + templates.AddRange(OrderApprovalTemplates(emailAccountId)); return templates; } diff --git a/src/Tests/Grand.Business.Messages.Tests/Commands/GetOrderApprovalTokensCommandHandlerTests.cs b/src/Tests/Grand.Business.Messages.Tests/Commands/GetOrderApprovalTokensCommandHandlerTests.cs new file mode 100644 index 0000000000..c50f33a481 --- /dev/null +++ b/src/Tests/Grand.Business.Messages.Tests/Commands/GetOrderApprovalTokensCommandHandlerTests.cs @@ -0,0 +1,116 @@ +using Grand.Business.Messages.Commands.Handlers.Messages; +using Grand.Business.Messages.Commands.Tokens; +using Grand.Domain.Customers; +using Grand.Domain.Localization; +using Grand.Domain.Orders; +using Grand.Domain.Stores; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Business.Messages.Tests.Commands; + +[TestClass] +public class GetOrderApprovalTokensCommandHandlerTests +{ + private GetOrderApprovalTokensCommandHandler _handler; + + [TestInitialize] + public void Init() + { + _handler = new GetOrderApprovalTokensCommandHandler(); + } + + private static OrderApproval Approval() + { + return new OrderApproval { + Id = "approval1", + Status = OrderApprovalStatus.Pending, + Summary = new OrderApprovalSummary { Total = "EUR 120.00" } + }; + } + + private static GetOrderApprovalTokensCommand Command(OrderApproval approval, DomainHost host = null) + { + return new GetOrderApprovalTokensCommand { + Approval = approval, + Buyer = new Customer { Email = "buyer@example.com", Username = "buyer" }, + Store = new Store { Url = "https://store.example/" }, + Host = host, + Language = new Language() + }; + } + + [TestMethod] + public async Task Handle_EncodesCommentAndItemValues() + { + //Arrange + var approval = Approval(); + approval.DecisionComment = " & more"; + approval.Items.Add(new OrderApprovalItem { + ProductName = "Desk", Sku = "SKU<1>", Quantity = 2, AttributeDescription = "Color: red", + UnitPrice = "EUR 10.00", LineTotal = "EUR 20.00" + }); + + //Act + var result = await _handler.Handle(Command(approval), CancellationToken.None); + + //Assert + Assert.AreEqual("<script>alert('x')</script> & more", result.DecisionComment); + Assert.IsFalse(result.ItemsHtml.Contains("Desk")); + Assert.IsFalse(result.ItemsHtml.Contains("red")); + Assert.IsFalse(result.ItemsHtml.Contains("SKU<1>")); + StringAssert.Contains(result.ItemsHtml, "<b>Desk</b>"); + StringAssert.Contains(result.ItemsHtml, "Color: <i>red</i>"); + StringAssert.Contains(result.ItemsHtml, "SKU<1>"); + StringAssert.Contains(result.ItemsHtml, "EUR 20.00"); + } + + [TestMethod] + public async Task Handle_BuildsAbsoluteLinks() + { + //Act + var result = await _handler.Handle(Command(Approval()), CancellationToken.None); + + //Assert + Assert.AreEqual("https://store.example/account/orderapproval?id=approval1", result.Url); + Assert.AreEqual("https://store.example/cart", result.CartUrl); + } + + [TestMethod] + public async Task Handle_HostOverridesStoreUrl() + { + //Act + var result = await _handler.Handle( + Command(Approval(), new DomainHost { Url = "https://shop.example/" }), CancellationToken.None); + + //Assert + Assert.AreEqual("https://shop.example/account/orderapproval?id=approval1", result.Url); + Assert.AreEqual("https://shop.example/cart", result.CartUrl); + } + + [TestMethod] + public async Task Handle_FillsBuyerStatusAndTotal() + { + //Arrange + var approval = Approval(); + approval.Status = OrderApprovalStatus.Rejected; + + //Act + var result = await _handler.Handle(Command(approval), CancellationToken.None); + + //Assert + Assert.AreEqual("buyer@example.com", result.BuyerEmail); + Assert.AreEqual("Rejected", result.Status); + Assert.AreEqual("EUR 120.00", result.Total); + } + + [TestMethod] + public async Task Handle_NoItemsNoComment_ReturnsEmptyValues() + { + //Act + var result = await _handler.Handle(Command(Approval()), CancellationToken.None); + + //Assert + Assert.AreEqual(string.Empty, result.DecisionComment); + Assert.IsNotNull(result.ItemsHtml); + } +} diff --git a/src/Tests/Grand.Business.Messages.Tests/Services/CustomerMessageProviderServiceOrderApprovalTests.cs b/src/Tests/Grand.Business.Messages.Tests/Services/CustomerMessageProviderServiceOrderApprovalTests.cs new file mode 100644 index 0000000000..0b5dfb6080 --- /dev/null +++ b/src/Tests/Grand.Business.Messages.Tests/Services/CustomerMessageProviderServiceOrderApprovalTests.cs @@ -0,0 +1,162 @@ +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Business.Core.Interfaces.Messages; +using Grand.Business.Messages.Commands.Tokens; +using Grand.Business.Messages.DotLiquidDrops; +using Grand.Business.Messages.Services; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Domain.Localization; +using Grand.Domain.Messages; +using Grand.Domain.Orders; +using Grand.Domain.Stores; +using Grand.Mediator; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Business.Messages.Tests.Services; + +[TestClass] +public class CustomerMessageProviderServiceOrderApprovalTests +{ + private readonly OrderApproval _approval = new() { Id = "approval1", CustomerId = "buyer1" }; + private readonly Customer _approver = new() { Id = "owner1", Email = "approver@example.com" }; + private readonly Customer _buyer = new() { Id = "buyer1", Email = "buyer@example.com" }; + private readonly Store _store = new() { Id = "store1", Url = "https://store.example/" }; + private Mock _mediatorMock; + private Mock _messageTemplateServiceMock; + private QueuedEmail _queued; + private Mock _queuedEmailServiceMock; + private CustomerMessageProviderService _service; + + [TestInitialize] + public void Init() + { + _messageTemplateServiceMock = new Mock(); + _queuedEmailServiceMock = new Mock(); + _queuedEmailServiceMock.Setup(x => x.InsertQueuedEmail(It.IsAny())) + .Callback(e => _queued = e); + + var languageService = new Mock(); + languageService.Setup(x => x.GetLanguageById(It.IsAny())) + .ReturnsAsync(new Language { Id = "en", Published = true }); + var emailAccountService = new Mock(); + emailAccountService.Setup(x => x.GetEmailAccountById(It.IsAny())) + .ReturnsAsync(new EmailAccount { Id = "ea", Email = "shop@example.com", DisplayName = "Shop" }); + var storeService = new Mock(); + + _mediatorMock = new Mock(); + _mediatorMock.Setup(x => x.Send(It.IsAny(), default)) + .ReturnsAsync(new LiquidOrderApproval { Total = "EUR 120.00" }); + + var lookup = new MessageLookupService(_messageTemplateServiceMock.Object, languageService.Object, + emailAccountService.Object, storeService.Object, new EmailAccountSettings()); + _service = new CustomerMessageProviderService(lookup, + new MessageProviderService(_queuedEmailServiceMock.Object), _mediatorMock.Object, new CommonSettings()); + } + + private void TemplateExists(string name, string subject, string body) + { + _messageTemplateServiceMock.Setup(x => x.GetMessageTemplateByName(name, "store1")) + .ReturnsAsync(new MessageTemplate { Name = name, IsActive = true, Subject = subject, Body = body }); + } + + [TestMethod] + public async Task SendRequested_ToApproverEmail_WithTemplate() + { + //Arrange + TemplateExists(MessageTemplateNames.OrderApprovalRequested, "Approve {{OrderApproval.Total}}", + "Total {{OrderApproval.Total}}"); + + //Act + var result = await _service.SendOrderApprovalRequestedMessage(_approval, _buyer, _approver, _store, "en"); + + //Assert + Assert.AreEqual(1, result); + Assert.IsNotNull(_queued); + Assert.AreEqual("approver@example.com", _queued.To); + Assert.AreEqual("shop@example.com", _queued.From); + Assert.AreEqual("Approve EUR 120.00", _queued.Subject); + Assert.AreEqual("Total EUR 120.00", _queued.Body); + _queuedEmailServiceMock.Verify(x => x.InsertQueuedEmail(It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task SendRequested_TemplateMissing_ReturnsZero() + { + //Act + var result = await _service.SendOrderApprovalRequestedMessage(_approval, _buyer, _approver, _store, "en"); + + //Assert + Assert.AreEqual(0, result); + _queuedEmailServiceMock.Verify(x => x.InsertQueuedEmail(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task SendRequested_TemplateInactive_ReturnsZero() + { + //Arrange + _messageTemplateServiceMock + .Setup(x => x.GetMessageTemplateByName(MessageTemplateNames.OrderApprovalRequested, "store1")) + .ReturnsAsync(new MessageTemplate { IsActive = false }); + + //Act + var result = await _service.SendOrderApprovalRequestedMessage(_approval, _buyer, _approver, _store, "en"); + + //Assert + Assert.AreEqual(0, result); + } + + [TestMethod] + public async Task SendDecided_ToBuyerEmail() + { + //Arrange + TemplateExists(MessageTemplateNames.OrderApprovalDecided, "Decided", "Body"); + + //Act + var result = await _service.SendOrderApprovalDecidedMessage(_approval, _buyer, _store, "en"); + + //Assert + Assert.AreEqual(1, result); + Assert.AreEqual("buyer@example.com", _queued.To); + Assert.AreEqual(Reference.Customer, _queued.Reference); + Assert.AreEqual("buyer1", _queued.ObjectId); + } + + [TestMethod] + public async Task SendDecided_TemplateMissing_ReturnsZero() + { + //Act + var result = await _service.SendOrderApprovalDecidedMessage(_approval, _buyer, _store, "en"); + + //Assert + Assert.AreEqual(0, result); + } + + [TestMethod] + public async Task SendRequested_LinksUseTheStorePrimaryDomain() + { + //Arrange + TemplateExists(MessageTemplateNames.OrderApprovalRequested, "s", "b"); + var store = new Store { + Id = "store1", Url = "https://store.example/", + Domains = { + new DomainHost { HostName = "primary.example", Url = "https://primary.example/", Primary = true } + } + }; + + //Act + await _service.SendOrderApprovalRequestedMessage(_approval, _buyer, _approver, store, "en"); + + //Assert + _mediatorMock.Verify(x => x.Send( + It.Is(c => c.Host.Url == "https://primary.example/"), default), Times.Once); + } + + [TestMethod] + public async Task SendRequested_NullApproval_Throws() + { + await Assert.ThrowsExactlyAsync(() => + _service.SendOrderApprovalRequestedMessage(null, _buyer, _approver, _store, "en")); + } +} diff --git a/src/Tests/Grand.Modules.Tests/Installer/MessageTemplateSeedTests.cs b/src/Tests/Grand.Modules.Tests/Installer/MessageTemplateSeedTests.cs index 8fbab46aa0..88eab329fd 100644 --- a/src/Tests/Grand.Modules.Tests/Installer/MessageTemplateSeedTests.cs +++ b/src/Tests/Grand.Modules.Tests/Installer/MessageTemplateSeedTests.cs @@ -18,11 +18,11 @@ internal static SortedSet Tokens(string text) => .Concat(TagVar.Matches(text).Select(m => m.Groups[1].Value))); [TestMethod] - public void Build_Returns51UniqueTemplates() + public void Build_Returns53UniqueTemplates() { var templates = MessageTemplateSeed.Build("ea"); - Assert.AreEqual(51, templates.Count); - Assert.AreEqual(51, templates.Select(t => t.Name).Distinct().Count()); + Assert.AreEqual(53, templates.Count); + Assert.AreEqual(53, templates.Select(t => t.Name).Distinct().Count()); Assert.IsTrue(templates.All(t => t.EmailAccountId == "ea")); } @@ -44,6 +44,8 @@ public void TokensAreSupersetOfBaseline() File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Installer", "message-template-tokens.baseline.json")))!; foreach (var t in MessageTemplateSeed.Build("ea")) { + //templates added after the baseline was taken have nothing to lose + if (!baseline.ContainsKey(t.Name)) continue; var now = Tokens(t.Subject + t.Body); var lost = baseline[t.Name].Where(x => !now.Contains(x)).ToList(); Assert.AreEqual(0, lost.Count, $"{t.Name} lost tokens: {string.Join(", ", lost)}"); diff --git a/src/Tests/Grand.Modules.Tests/Installer/OrderApprovalTemplateSeedTests.cs b/src/Tests/Grand.Modules.Tests/Installer/OrderApprovalTemplateSeedTests.cs new file mode 100644 index 0000000000..215a57379e --- /dev/null +++ b/src/Tests/Grand.Modules.Tests/Installer/OrderApprovalTemplateSeedTests.cs @@ -0,0 +1,68 @@ +using Grand.Business.Messages.DotLiquidDrops; +using Grand.Domain.Messages; +using Grand.Module.Installer.Services.MessageTemplates; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Modules.Tests.Installer; + +[TestClass] +public class OrderApprovalTemplateSeedTests +{ + private static MessageTemplate Template(string name) + { + return MessageTemplateSeed.Build("ea").Single(t => t.Name == name); + } + + [TestMethod] + [DataRow("OrderApproval.Requested")] + [DataRow("OrderApproval.Decided")] + public void OrderApprovalTemplates_AreActiveAndUseOnlyBackedTokens(string name) + { + var template = Template(name); + Assert.IsTrue(template.IsActive); + + var backed = LiquidExtensions.GetTokens(typeof(LiquidOrderApproval), typeof(LiquidCustomer), typeof(LiquidStore)) + .Select(x => x.Trim('{', '}')).ToHashSet(); + var used = MessageTemplateSeedTests.Tokens(template.Subject + template.Body) + .Where(x => x.StartsWith("OrderApproval.") || x.StartsWith("Customer.") || x.StartsWith("Store.")); + foreach (var token in used) + Assert.IsTrue(backed.Contains(token), $"{name} uses {token}, which no drop provides"); + } + + [TestMethod] + public void Requested_RendersEncodedBuyerAndItemsHtml() + { + var liquidObject = new LiquidObject { + OrderApproval = new LiquidOrderApproval { + BuyerName = "A & B", + BuyerEmail = "buyer@example.com", + Total = "EUR 120.00", + ItemsHtml = "ITEMS
", + Url = "https://store.example/account/orderapproval?id=1" + } + }; + + var subject = LiquidExtensions.Render(liquidObject, Template("OrderApproval.Requested").Subject); + var body = LiquidExtensions.Render(liquidObject, Template("OrderApproval.Requested").Body); + + StringAssert.Contains(subject, "A & B"); + StringAssert.Contains(body, "A <b>& B"); + Assert.IsFalse(body.Contains("A ")); + StringAssert.Contains(body, "ITEMS
"); + StringAssert.Contains(body, "href='https://store.example/account/orderapproval?id=1'"); + } + + [TestMethod] + public void Decided_ShowsCommentOnlyWhenPresent() + { + var withComment = LiquidExtensions.Render( + new LiquidObject { OrderApproval = new LiquidOrderApproval { DecisionComment = "Too expensive" } }, + Template("OrderApproval.Decided").Body); + var withoutComment = LiquidExtensions.Render( + new LiquidObject { OrderApproval = new LiquidOrderApproval { DecisionComment = "" } }, + Template("OrderApproval.Decided").Body); + + StringAssert.Contains(withComment, "Too expensive"); + Assert.IsFalse(withoutComment.Contains("Comment from the approver")); + } +} From 304c3e1b544a6873be51d9bcee09764f3cbde9b8 Mon Sep 17 00:00:00 2001 From: support Date: Sun, 25 Oct 2026 08:23:49 +0100 Subject: [PATCH 11/28] Let buyers request and approvers decide order approvals --- .../Checkout}/OrderApprovalFingerprint.cs | 2 +- .../Orders/OrderApprovalFingerprintTests.cs | 2 +- .../Orders/OrderApprovalCommandsTests.cs | 494 ++++++++++++++++++ .../DecideOrderApprovalCommandHandler.cs | 64 +++ .../SubmitOrderApprovalCommandHandler.cs | 202 +++++++ .../WithdrawOrderApprovalCommandHandler.cs | 35 ++ .../Orders/DecideOrderApprovalCommand.cs | 21 + .../Orders/SubmitOrderApprovalCommand.cs | 20 + .../Orders/WithdrawOrderApprovalCommand.cs | 14 + 9 files changed, 852 insertions(+), 2 deletions(-) rename src/Business/{Grand.Business.Checkout/Services/Orders => Grand.Business.Core/Utilities/Checkout}/OrderApprovalFingerprint.cs (97%) create mode 100644 src/Tests/Grand.Web.Tests/Commands/Orders/OrderApprovalCommandsTests.cs create mode 100644 src/Web/Grand.Web/Commands/Handler/Orders/DecideOrderApprovalCommandHandler.cs create mode 100644 src/Web/Grand.Web/Commands/Handler/Orders/SubmitOrderApprovalCommandHandler.cs create mode 100644 src/Web/Grand.Web/Commands/Handler/Orders/WithdrawOrderApprovalCommandHandler.cs create mode 100644 src/Web/Grand.Web/Commands/Models/Orders/DecideOrderApprovalCommand.cs create mode 100644 src/Web/Grand.Web/Commands/Models/Orders/SubmitOrderApprovalCommand.cs create mode 100644 src/Web/Grand.Web/Commands/Models/Orders/WithdrawOrderApprovalCommand.cs diff --git a/src/Business/Grand.Business.Checkout/Services/Orders/OrderApprovalFingerprint.cs b/src/Business/Grand.Business.Core/Utilities/Checkout/OrderApprovalFingerprint.cs similarity index 97% rename from src/Business/Grand.Business.Checkout/Services/Orders/OrderApprovalFingerprint.cs rename to src/Business/Grand.Business.Core/Utilities/Checkout/OrderApprovalFingerprint.cs index c89bca8c15..e936db0718 100644 --- a/src/Business/Grand.Business.Checkout/Services/Orders/OrderApprovalFingerprint.cs +++ b/src/Business/Grand.Business.Core/Utilities/Checkout/OrderApprovalFingerprint.cs @@ -2,7 +2,7 @@ using System.Globalization; using System.Security.Cryptography; -namespace Grand.Business.Checkout.Services.Orders; +namespace Grand.Business.Core.Utilities.Checkout; /// /// Identifies the content of a shopping cart, so that an approval holds for exactly the cart it was given for diff --git a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderApprovalFingerprintTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderApprovalFingerprintTests.cs index 37cb803d2c..ee44c79d70 100644 --- a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderApprovalFingerprintTests.cs +++ b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderApprovalFingerprintTests.cs @@ -1,4 +1,4 @@ -using Grand.Business.Checkout.Services.Orders; +using Grand.Business.Core.Utilities.Checkout; using Grand.Domain.Common; using Grand.Domain.Orders; using Microsoft.VisualStudio.TestTools.UnitTesting; diff --git a/src/Tests/Grand.Web.Tests/Commands/Orders/OrderApprovalCommandsTests.cs b/src/Tests/Grand.Web.Tests/Commands/Orders/OrderApprovalCommandsTests.cs new file mode 100644 index 0000000000..02d996909c --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Commands/Orders/OrderApprovalCommandsTests.cs @@ -0,0 +1,494 @@ +using Grand.Business.Core.Interfaces.Catalog.Prices; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Catalog.Tax; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.Messages; +using Grand.Business.Core.Utilities.Catalog; +using Grand.Business.Core.Utilities.Checkout; +using Grand.Domain; +using Grand.Domain.Common; +using Grand.Domain.Catalog; +using Grand.Domain.Customers; +using Grand.Domain.Directory; +using Grand.Domain.Localization; +using Grand.Domain.Orders; +using Grand.Domain.Stores; +using Grand.Domain.Tax; +using Grand.Mediator; +using Grand.Web.Commands.Handler.Orders; +using Grand.Web.Commands.Models.Orders; +using Grand.Web.Common.Localization; +using Grand.Web.Features.Models.ShoppingCart; +using Grand.Web.Models.ShoppingCart; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using StoreEntity = Grand.Domain.Stores.Store; + +namespace Grand.Web.Tests.Commands.Orders; + +[TestClass] +public class OrderApprovalCommandsTests +{ + private const string OwnerId = "owner"; + private const string StoreId = "store1"; + + private Mock _approvals; + private Mock _messages; + private Mock _customers; + + private static IRequestTranslationService Translation() + { + var translation = new Mock(); + translation.Setup(t => t.GetResource(It.IsAny())).Returns(key => key); + return translation.Object; + } + + private static Customer Buyer() => new() { + Id = "buyer", OwnerId = OwnerId, SubAccountRole = SubAccountRole.Buyer, Active = true, + Email = "buyer@example.com" + }; + + private static StoreEntity Shop() => new() { Id = StoreId }; + + private static OrderApproval Approval(OrderApprovalStatus status = OrderApprovalStatus.Pending, + string id = "a1") => new() { + Id = id, CustomerId = "buyer", CompanyOwnerId = OwnerId, StoreId = StoreId, Status = status, + CartFingerprint = "fp", Amount = 150 + }; + + [TestInitialize] + public void Init() + { + _approvals = new Mock(); + _messages = new Mock(); + _customers = new Mock(); + } + + #region Submit + + private Mock _cartService; + private Mock _mediator; + private Mock _attributeFormatter; + private Currency _currency; + private List _cart; + private SubmitOrderApprovalCommandHandler _submit; + + private void InitSubmit(bool blocked = true, OrderApproval active = null) + { + _currency = new Currency { CurrencyCode = "EUR" }; + var product = new Product { Id = "p1", Name = "Widget", Sku = "W-1" }; + _cart = [ + new ShoppingCartItem { + Id = "c1", ProductId = "p1", Quantity = 2, ShoppingCartTypeId = ShoppingCartType.ShoppingCart, + Attributes = [new CustomAttribute { Key = "color", Value = "red" }] + } + ]; + + _cartService = new Mock(); + _cartService.Setup(s => s.GetShoppingCart(It.IsAny(), StoreId, It.IsAny())) + .ReturnsAsync(_cart); + + var productService = new Mock(); + productService.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + + var pricing = new Mock(); + pricing.Setup(p => p.GetUnitPrice(It.IsAny(), product, It.IsAny(), + It.IsAny(), It.IsAny(), true)) + .ReturnsAsync((55d, 0d, new List())); + pricing.Setup(p => p.GetSubTotal(It.IsAny(), product, It.IsAny(), + It.IsAny(), It.IsAny(), true)) + .ReturnsAsync((110d, 0d, new List())); + + var tax = new Mock(); + tax.Setup(t => t.GetProductPrice(product, It.IsAny(), true, It.IsAny(), It.IsAny())) + .ReturnsAsync((Product _, double price, bool _, Customer _, StoreEntity _) => (price, 0d)); + + var formatter = new Mock(); + formatter.Setup(f => f.FormatPrice(It.IsAny(), It.IsAny())) + .Returns((price, c) => $"{price:0.##} {c.CurrencyCode}"); + + _attributeFormatter = new Mock(); + _attributeFormatter.Setup(f => f.FormatAttributes(product, It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync("Color: red"); + + _mediator = new Mock(); + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(new OrderTotalsModel { + SubTotal = "110 EUR", SubTotalDiscountValue = -10, OrderTotalDiscountValue = -5, + SelectedShippingMethod = "Ground", Shipping = "20 EUR", Tax = "30 EUR", OrderTotal = "150 EUR" + }); + + var state = new OrderApprovalCheckoutState(true, 100, 150, blocked ? null : Approval(OrderApprovalStatus.Approved)); + _approvals.Setup(a => a.GetCheckoutState(It.IsAny>(), It.IsAny())) + .ReturnsAsync(state); + _approvals.Setup(a => a.GetActive("buyer", StoreId)).ReturnsAsync(active); + + _customers.Setup(c => c.GetCustomerById(OwnerId)).ReturnsAsync( + new Customer { Id = OwnerId, Active = true, Email = "owner@example.com" }); + + _submit = new SubmitOrderApprovalCommandHandler(_approvals.Object, _cartService.Object, + productService.Object, pricing.Object, tax.Object, formatter.Object, _attributeFormatter.Object, + _customers.Object, _messages.Object, _mediator.Object, Translation()); + } + + private void SetCompany(params Customer[] members) + { + _customers.Setup(c => c.GetAllCustomers(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), StoreId, OwnerId, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>>(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new PagedList(members, 0, int.MaxValue)); + } + + private SubmitOrderApprovalCommand SubmitCommand(Customer buyer = null) + { + buyer ??= Buyer(); + return new SubmitOrderApprovalCommand { + Customer = buyer, Store = Shop(), Language = new Language { Id = "en" }, + Context = new CalculationContext(buyer, Shop(), _currency, TaxDisplayType.IncludingTax), + Host = new DomainHost { HostName = "shop.example" } + }; + } + + private static Customer SubAccount(string id, SubAccountRole role, string email = null, bool active = true, + bool deleted = false) => new() { + Id = id, OwnerId = OwnerId, SubAccountRole = role, Active = active, Deleted = deleted, + StoreId = StoreId, Email = email ?? $"{id}@example.com" + }; + + [TestMethod] + public async Task Submit_NotBlocked_Refused() + { + InitSubmit(false); + + var (success, error) = await _submit.Handle(SubmitCommand(), default); + + Assert.IsFalse(success); + Assert.AreEqual("OrderApproval.Submit.NotNeeded", error); + _approvals.Verify(a => a.Insert(It.IsAny()), Times.Never); + _approvals.Verify(a => a.Update(It.IsAny()), Times.Never); + _messages.Verify(m => m.SendOrderApprovalRequestedMessage(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Submit_Blocked_InsertsPendingWithServerSnapshot() + { + InitSubmit(); + SetCompany(); + OrderApproval inserted = null; + _approvals.Setup(a => a.Insert(It.IsAny())).Callback(a => inserted = a) + .Returns(Task.CompletedTask); + + var (success, error) = await _submit.Handle(SubmitCommand(), default); + + Assert.IsTrue(success); + Assert.AreEqual(string.Empty, error); + Assert.IsNotNull(inserted); + Assert.AreEqual(OrderApprovalStatus.Pending, inserted.Status); + Assert.AreEqual("buyer", inserted.CustomerId); + Assert.AreEqual(OwnerId, inserted.CompanyOwnerId); + Assert.AreEqual(StoreId, inserted.StoreId); + Assert.AreEqual(150, inserted.Amount); + Assert.AreEqual("EUR", inserted.CurrencyCode); + Assert.AreEqual(OrderApprovalFingerprint.Compute(_cart), inserted.CartFingerprint); + + var item = inserted.Items.Single(); + Assert.AreEqual("p1", item.ProductId); + Assert.AreEqual("Widget", item.ProductName); + Assert.AreEqual("W-1", item.Sku); + Assert.AreEqual(2, item.Quantity); + Assert.AreEqual("Color: red", item.AttributeDescription); + Assert.AreEqual("55 EUR", item.UnitPrice); + Assert.AreEqual("110 EUR", item.LineTotal); + + Assert.AreEqual("110 EUR", inserted.Summary.SubTotal); + Assert.AreEqual("-15 EUR", inserted.Summary.Discount); + Assert.AreEqual("Ground", inserted.Summary.ShippingMethod); + Assert.AreEqual("20 EUR", inserted.Summary.Shipping); + Assert.AreEqual("30 EUR", inserted.Summary.Tax); + Assert.AreEqual("150 EUR", inserted.Summary.Total); + } + + [TestMethod] + public async Task Submit_StoresAttributesAsPlainText() + { + InitSubmit(); + SetCompany(); + + await _submit.Handle(SubmitCommand(), default); + + _attributeFormatter.Verify(f => f.FormatAttributes(It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny(), ", ", false, + It.IsAny(), It.IsAny(), It.IsAny(), false, It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task Submit_WithdrawsPreviousActive() + { + var previous = Approval(OrderApprovalStatus.Approved, "old"); + InitSubmit(active: previous); + SetCompany(); + var order = new List(); + _approvals.Setup(a => a.Update(previous)).Callback(() => order.Add("withdraw")).Returns(Task.CompletedTask); + _approvals.Setup(a => a.Insert(It.IsAny())).Callback(() => order.Add("insert")) + .Returns(Task.CompletedTask); + + var (success, _) = await _submit.Handle(SubmitCommand(), default); + + Assert.IsTrue(success); + Assert.AreEqual(OrderApprovalStatus.Withdrawn, previous.Status); + CollectionAssert.AreEqual(new[] { "withdraw", "insert" }, order); + } + + [TestMethod] + public async Task Submit_NotifiesOwnerAndApproversOnly() + { + InitSubmit(); + SetCompany( + SubAccount("approver", SubAccountRole.Approver), + SubAccount("buyer2", SubAccountRole.Buyer), + SubAccount("inactive", SubAccountRole.Approver, active: false), + SubAccount("deleted", SubAccountRole.Approver, deleted: true), + SubAccount("noemail", SubAccountRole.Approver, email: " "), + Buyer()); + var recipients = new List(); + _messages.Setup(m => m.SendOrderApprovalRequestedMessage(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, approver, _, _, _) => + recipients.Add(approver.Id)) + .ReturnsAsync(1); + + await _submit.Handle(SubmitCommand(), default); + + CollectionAssert.AreEquivalent(new[] { OwnerId, "approver" }, recipients); + } + + [TestMethod] + public async Task Submit_SendsInTheBuyersLanguageWithTheRequestHost() + { + InitSubmit(); + SetCompany(); + var command = SubmitCommand(); + + await _submit.Handle(command, default); + + _messages.Verify(m => m.SendOrderApprovalRequestedMessage(It.IsAny(), command.Customer, + It.Is(c => c.Id == OwnerId), command.Store, "en", command.Host), Times.Once); + } + + #endregion + + #region Decide + + private DecideOrderApprovalCommandHandler Decider() + { + _customers.Setup(c => c.GetCustomerById("buyer")).ReturnsAsync(Buyer()); + return new DecideOrderApprovalCommandHandler(_approvals.Object, _customers.Object, _messages.Object); + } + + private static DecideOrderApprovalCommand DecideCommand(Customer approver, bool approve = true, + string comment = null, string id = "a1") => new() { + Approver = approver, Store = Shop(), ApprovalId = id, Approve = approve, Comment = comment, + Language = new Language { Id = "en" }, Host = new DomainHost { HostName = "shop.example" } + }; + + [TestMethod] + public async Task Decide_Approver_Approves() + { + var approval = Approval(); + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(approval); + var approver = SubAccount("approver", SubAccountRole.Approver); + + var result = await Decider().Handle(DecideCommand(approver, true, " ok "), default); + + Assert.IsTrue(result); + Assert.AreEqual(OrderApprovalStatus.Approved, approval.Status); + Assert.AreEqual("approver", approval.DecidedByCustomerId); + Assert.AreEqual("ok", approval.DecisionComment); + Assert.IsNotNull(approval.DecidedOnUtc); + _approvals.Verify(a => a.Update(approval), Times.Once); + _messages.Verify(m => m.SendOrderApprovalDecidedMessage(approval, It.Is(c => c.Id == "buyer"), + It.Is(s => s.Id == StoreId), "en", It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task Decide_Owner_Rejects() + { + var approval = Approval(); + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(approval); + var owner = new Customer { Id = OwnerId, Active = true }; + + var result = await Decider().Handle(DecideCommand(owner, false), default); + + Assert.IsTrue(result); + Assert.AreEqual(OrderApprovalStatus.Rejected, approval.Status); + Assert.AreEqual(OwnerId, approval.DecidedByCustomerId); + Assert.IsNull(approval.DecisionComment); + _approvals.Verify(a => a.Update(approval), Times.Once); + } + + [TestMethod] + public async Task Decide_Buyer_Refused() + { + var approval = Approval(); + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(approval); + + var result = await Decider().Handle(DecideCommand(Buyer()), default); + + Assert.IsFalse(result); + Assert.AreEqual(OrderApprovalStatus.Pending, approval.Status); + _approvals.Verify(a => a.Update(It.IsAny()), Times.Never); + _messages.Verify(m => m.SendOrderApprovalDecidedMessage(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Decide_OtherCompany_Refused() + { + // the approval is the company's; the stranger's own company id finds nothing + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(Approval()); + + var result = await Decider().Handle(DecideCommand(new Customer { Id = "stranger" }), default); + + Assert.IsFalse(result); + _approvals.Verify(a => a.GetById("a1", "stranger"), Times.Once); + _approvals.Verify(a => a.Update(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Decide_MissingId_Refused() + { + var result = await Decider().Handle(DecideCommand(new Customer { Id = OwnerId }, id: "nope"), default); + + Assert.IsFalse(result); + _approvals.Verify(a => a.Update(It.IsAny()), Times.Never); + } + + [TestMethod] + [DataRow(OrderApprovalStatus.Approved)] + [DataRow(OrderApprovalStatus.Rejected)] + [DataRow(OrderApprovalStatus.Withdrawn)] + [DataRow(OrderApprovalStatus.Used)] + public async Task Decide_NotPending_Refused(OrderApprovalStatus status) + { + var approval = Approval(status); + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(approval); + + var result = await Decider().Handle(DecideCommand(new Customer { Id = OwnerId }), default); + + Assert.IsFalse(result); + Assert.AreEqual(status, approval.Status); + _approvals.Verify(a => a.Update(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Decide_AnotherStore_Refused() + { + var approval = Approval(); + approval.StoreId = "store2"; + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(approval); + + var result = await Decider().Handle(DecideCommand(new Customer { Id = OwnerId }), default); + + Assert.IsFalse(result); + _approvals.Verify(a => a.Update(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Decide_CommentTruncatedAt1000() + { + var approval = Approval(); + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(approval); + + await Decider().Handle(DecideCommand(new Customer { Id = OwnerId }, false, new string('x', 1500)), default); + + Assert.AreEqual(1000, approval.DecisionComment.Length); + } + + [TestMethod] + [DataRow("")] + [DataRow(" ")] + [DataRow(null)] + public async Task Decide_BlankComment_IsNull(string comment) + { + var approval = Approval(); + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(approval); + + await Decider().Handle(DecideCommand(new Customer { Id = OwnerId }, true, comment), default); + + Assert.IsNull(approval.DecisionComment); + } + + #endregion + + #region Withdraw + + private WithdrawOrderApprovalCommandHandler Withdrawer() => new(_approvals.Object); + + [TestMethod] + [DataRow(OrderApprovalStatus.Pending)] + [DataRow(OrderApprovalStatus.Approved)] + public async Task Withdraw_Own_Withdrawn(OrderApprovalStatus status) + { + var approval = Approval(status); + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(approval); + + var result = await Withdrawer().Handle( + new WithdrawOrderApprovalCommand { Customer = Buyer(), Store = Shop(), ApprovalId = "a1" }, default); + + Assert.IsTrue(result); + Assert.AreEqual(OrderApprovalStatus.Withdrawn, approval.Status); + _approvals.Verify(a => a.Update(approval), Times.Once); + } + + [TestMethod] + public async Task Withdraw_SomeoneElses_Refused() + { + var approval = Approval(); + approval.CustomerId = "colleague"; + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(approval); + + var result = await Withdrawer().Handle( + new WithdrawOrderApprovalCommand { Customer = Buyer(), Store = Shop(), ApprovalId = "a1" }, default); + + Assert.IsFalse(result); + Assert.AreEqual(OrderApprovalStatus.Pending, approval.Status); + _approvals.Verify(a => a.Update(It.IsAny()), Times.Never); + } + + [TestMethod] + [DataRow(OrderApprovalStatus.Rejected)] + [DataRow(OrderApprovalStatus.Used)] + [DataRow(OrderApprovalStatus.Withdrawn)] + public async Task Withdraw_Finished_Refused(OrderApprovalStatus status) + { + var approval = Approval(status); + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(approval); + + var result = await Withdrawer().Handle( + new WithdrawOrderApprovalCommand { Customer = Buyer(), Store = Shop(), ApprovalId = "a1" }, default); + + Assert.IsFalse(result); + Assert.AreEqual(status, approval.Status); + _approvals.Verify(a => a.Update(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Withdraw_MissingId_Refused() + { + var result = await Withdrawer().Handle( + new WithdrawOrderApprovalCommand { Customer = Buyer(), Store = Shop(), ApprovalId = "nope" }, default); + + Assert.IsFalse(result); + } + + #endregion +} diff --git a/src/Web/Grand.Web/Commands/Handler/Orders/DecideOrderApprovalCommandHandler.cs b/src/Web/Grand.Web/Commands/Handler/Orders/DecideOrderApprovalCommandHandler.cs new file mode 100644 index 0000000000..946de9a698 --- /dev/null +++ b/src/Web/Grand.Web/Commands/Handler/Orders/DecideOrderApprovalCommandHandler.cs @@ -0,0 +1,64 @@ +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.Messages; +using Grand.Business.Core.Utilities.Checkout; +using Grand.Domain.Orders; +using Grand.Mediator; +using Grand.Web.Commands.Models.Orders; + +namespace Grand.Web.Commands.Handler.Orders; + +public class DecideOrderApprovalCommandHandler : IRequestHandler +{ + private const int MaxCommentLength = 1000; + + private readonly ICustomerService _customerService; + private readonly ICustomerMessageProviderService _messageProviderService; + private readonly IOrderApprovalService _orderApprovalService; + + public DecideOrderApprovalCommandHandler(IOrderApprovalService orderApprovalService, + ICustomerService customerService, ICustomerMessageProviderService messageProviderService) + { + _orderApprovalService = orderApprovalService; + _customerService = customerService; + _messageProviderService = messageProviderService; + } + + public async Task Handle(DecideOrderApprovalCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request.Approver); + ArgumentNullException.ThrowIfNull(request.Store); + + // every refusal gives the same answer: a foreign, missing or finished request and a non-approver + // cannot be told apart + if (!OrderApprovalRules.CanApprove(request.Approver)) + return false; + + var approval = await _orderApprovalService.GetById(request.ApprovalId, + OrderApprovalRules.CompanyId(request.Approver)); + if (approval == null || approval.StoreId != request.Store.Id || approval.Status != OrderApprovalStatus.Pending) + return false; + + approval.Status = request.Approve ? OrderApprovalStatus.Approved : OrderApprovalStatus.Rejected; + approval.DecidedOnUtc = DateTime.UtcNow; + approval.DecidedByCustomerId = request.Approver.Id; + approval.DecisionComment = NormalizeComment(request.Comment); + await _orderApprovalService.Update(approval); + + var buyer = await _customerService.GetCustomerById(approval.CustomerId); + if (buyer != null && !string.IsNullOrWhiteSpace(buyer.Email)) + await _messageProviderService.SendOrderApprovalDecidedMessage(approval, buyer, request.Store, + request.Language?.Id, request.Host); + + return true; + } + + private static string NormalizeComment(string comment) + { + if (string.IsNullOrWhiteSpace(comment)) + return null; + + comment = comment.Trim(); + return comment.Length > MaxCommentLength ? comment[..MaxCommentLength] : comment; + } +} diff --git a/src/Web/Grand.Web/Commands/Handler/Orders/SubmitOrderApprovalCommandHandler.cs b/src/Web/Grand.Web/Commands/Handler/Orders/SubmitOrderApprovalCommandHandler.cs new file mode 100644 index 0000000000..276867e282 --- /dev/null +++ b/src/Web/Grand.Web/Commands/Handler/Orders/SubmitOrderApprovalCommandHandler.cs @@ -0,0 +1,202 @@ +using Grand.Business.Core.Extensions; +using Grand.Business.Core.Interfaces.Catalog.Prices; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Catalog.Tax; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.Messages; +using Grand.Business.Core.Utilities.Checkout; +using Grand.Domain.Catalog; +using Grand.Domain.Customers; +using Grand.Domain.Orders; +using Grand.Domain.Tax; +using Grand.Mediator; +using Grand.Web.Commands.Models.Orders; +using Grand.Web.Common.Localization; +using Grand.Web.Features.Models.ShoppingCart; + +namespace Grand.Web.Commands.Handler.Orders; + +public class SubmitOrderApprovalCommandHandler : IRequestHandler +{ + private readonly ICustomerService _customerService; + private readonly IMediator _mediator; + private readonly ICustomerMessageProviderService _messageProviderService; + private readonly IOrderApprovalService _orderApprovalService; + private readonly IPriceFormatter _priceFormatter; + private readonly IPricingService _pricingService; + private readonly IProductAttributeFormatter _productAttributeFormatter; + private readonly IProductService _productService; + private readonly IShoppingCartService _shoppingCartService; + private readonly ITaxService _taxService; + private readonly IRequestTranslationService _translationService; + + public SubmitOrderApprovalCommandHandler( + IOrderApprovalService orderApprovalService, + IShoppingCartService shoppingCartService, + IProductService productService, + IPricingService pricingService, + ITaxService taxService, + IPriceFormatter priceFormatter, + IProductAttributeFormatter productAttributeFormatter, + ICustomerService customerService, + ICustomerMessageProviderService messageProviderService, + IMediator mediator, + IRequestTranslationService translationService) + { + _orderApprovalService = orderApprovalService; + _shoppingCartService = shoppingCartService; + _productService = productService; + _pricingService = pricingService; + _taxService = taxService; + _priceFormatter = priceFormatter; + _productAttributeFormatter = productAttributeFormatter; + _customerService = customerService; + _messageProviderService = messageProviderService; + _mediator = mediator; + _translationService = translationService; + } + + public async Task<(bool success, string error)> Handle(SubmitOrderApprovalCommand request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request.Customer); + ArgumentNullException.ThrowIfNull(request.Store); + ArgumentNullException.ThrowIfNull(request.Context); + + // the server works out the cart, the total and whether an approval is needed; nothing comes from the client + var cart = await _shoppingCartService.GetShoppingCart(request.Customer, request.Store.Id, + ShoppingCartType.ShoppingCart, ShoppingCartType.Auctions); + + var state = await _orderApprovalService.GetCheckoutState(cart, request.Context); + if (!state.Blocked || !state.Total.HasValue) + return (false, _translationService.GetResource("OrderApproval.Submit.NotNeeded")); + + var approval = new OrderApproval { + StoreId = request.Store.Id, + CompanyOwnerId = request.Customer.OwnerId, + CustomerId = request.Customer.Id, + Status = OrderApprovalStatus.Pending, + CartFingerprint = OrderApprovalFingerprint.Compute(cart), + Amount = state.Total.Value, + CurrencyCode = request.Context.Currency.CurrencyCode, + Summary = await PrepareSummary(request, cart) + }; + foreach (var item in await PrepareItems(request, cart)) + approval.Items.Add(item); + + // one active request per buyer and store: a new request replaces the pending or approved one + var previous = await _orderApprovalService.GetActive(request.Customer.Id, request.Store.Id); + if (previous != null) + { + previous.Status = OrderApprovalStatus.Withdrawn; + await _orderApprovalService.Update(previous); + } + + await _orderApprovalService.Insert(approval); + + foreach (var approver in await GetApprovers(request.Customer, request.Store.Id)) + await _messageProviderService.SendOrderApprovalRequestedMessage(approval, request.Customer, approver, + request.Store, request.Language?.Id, request.Host); + + return (true, string.Empty); + } + + private async Task> PrepareItems(SubmitOrderApprovalCommand request, + IList cart) + { + var context = request.Context; + var includingTax = context.TaxDisplayType == TaxDisplayType.IncludingTax; + var items = new List(); + + foreach (var sci in cart) + { + var product = await _productService.GetProductById(sci.ProductId); + if (product == null) + continue; + + var item = new OrderApprovalItem { + ProductId = product.Id, + ProductName = product.GetTranslation(x => x.Name, request.Language?.Id), + Sku = product.FormatSku(sci.Attributes), + Quantity = sci.Quantity, + // plain text: the e-mails and the views encode it when they render it + AttributeDescription = await _productAttributeFormatter.FormatAttributes(product, sci.Attributes, + context, request.Language, request.Host, ", ", false, allowHyperlinks: false) + }; + + if (product.CallForPrice) + { + item.UnitPrice = _translationService.GetResource("Products.CallForPrice"); + item.LineTotal = item.UnitPrice; + } + else + { + var unitPrice = await _pricingService.GetUnitPrice(sci, product, context.Customer, context.Store, + context.Currency); + var unitPriceShown = await _taxService.GetProductPrice(product, unitPrice.unitprice, includingTax, + context.Customer, context.Store); + item.UnitPrice = _priceFormatter.FormatPrice(unitPriceShown.productprice, context.Currency); + + var subTotal = await _pricingService.GetSubTotal(sci, product, context.Customer, context.Store, + context.Currency); + var subTotalShown = await _taxService.GetProductPrice(product, subTotal.subTotal, includingTax, + context.Customer, context.Store); + item.LineTotal = _priceFormatter.FormatPrice(subTotalShown.productprice, context.Currency); + } + + items.Add(item); + } + + return items; + } + + private async Task PrepareSummary(SubmitOrderApprovalCommand request, + IList cart) + { + var context = request.Context; + + // the same totals the confirm step shows + var totals = await _mediator.Send(new GetOrderTotals { + Customer = context.Customer, + Language = request.Language, + Currency = context.Currency, + Store = context.Store, + TaxDisplayType = context.TaxDisplayType, + Cart = cart + }); + + // discounts on the sub total and on the order total are two lines on the confirm step; one here + var discount = totals.SubTotalDiscountValue + totals.OrderTotalDiscountValue; + + return new OrderApprovalSummary { + SubTotal = totals.SubTotal, + Discount = discount != 0 ? _priceFormatter.FormatPrice(discount, context.Currency) : null, + ShippingMethod = totals.SelectedShippingMethod, + Shipping = totals.Shipping, + Tax = totals.Tax, + Total = totals.OrderTotal + }; + } + + /// + /// The company owner and every active approver sub-account of the store that has an e-mail address + /// + private async Task> GetApprovers(Customer buyer, string storeId) + { + var approvers = new List(); + + var owner = await _customerService.GetCustomerById(buyer.OwnerId); + if (owner is { Active: true, Deleted: false }) + approvers.Add(owner); + + var subAccounts = await _customerService.GetAllCustomers(ownerId: buyer.OwnerId, storeId: storeId); + approvers.AddRange(subAccounts.Where(x => x.SubAccountRole == SubAccountRole.Approver && x.Active && + !x.Deleted && x.StoreId == storeId)); + + return approvers + .Where(x => x.Id != buyer.Id && !string.IsNullOrWhiteSpace(x.Email)) + .DistinctBy(x => x.Id) + .ToList(); + } +} diff --git a/src/Web/Grand.Web/Commands/Handler/Orders/WithdrawOrderApprovalCommandHandler.cs b/src/Web/Grand.Web/Commands/Handler/Orders/WithdrawOrderApprovalCommandHandler.cs new file mode 100644 index 0000000000..e568b3d3bb --- /dev/null +++ b/src/Web/Grand.Web/Commands/Handler/Orders/WithdrawOrderApprovalCommandHandler.cs @@ -0,0 +1,35 @@ +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Utilities.Checkout; +using Grand.Domain.Orders; +using Grand.Mediator; +using Grand.Web.Commands.Models.Orders; + +namespace Grand.Web.Commands.Handler.Orders; + +public class WithdrawOrderApprovalCommandHandler : IRequestHandler +{ + private readonly IOrderApprovalService _orderApprovalService; + + public WithdrawOrderApprovalCommandHandler(IOrderApprovalService orderApprovalService) + { + _orderApprovalService = orderApprovalService; + } + + public async Task Handle(WithdrawOrderApprovalCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request.Customer); + ArgumentNullException.ThrowIfNull(request.Store); + + var approval = await _orderApprovalService.GetById(request.ApprovalId, + OrderApprovalRules.CompanyId(request.Customer)); + + // a request can be withdrawn only by its own buyer, while it is still open or waiting to be used + if (approval == null || approval.CustomerId != request.Customer.Id || approval.StoreId != request.Store.Id || + approval.Status is not (OrderApprovalStatus.Pending or OrderApprovalStatus.Approved)) + return false; + + approval.Status = OrderApprovalStatus.Withdrawn; + await _orderApprovalService.Update(approval); + return true; + } +} diff --git a/src/Web/Grand.Web/Commands/Models/Orders/DecideOrderApprovalCommand.cs b/src/Web/Grand.Web/Commands/Models/Orders/DecideOrderApprovalCommand.cs new file mode 100644 index 0000000000..1d890ef164 --- /dev/null +++ b/src/Web/Grand.Web/Commands/Models/Orders/DecideOrderApprovalCommand.cs @@ -0,0 +1,21 @@ +using Grand.Domain.Customers; +using Grand.Domain.Localization; +using Grand.Domain.Stores; +using Grand.Mediator; + +namespace Grand.Web.Commands.Models.Orders; + +/// +/// Approves or rejects a pending order approval of the approver's company. Returns false, without any +/// detail, when the approver may not decide it. +/// +public class DecideOrderApprovalCommand : IRequest +{ + public Customer Approver { get; set; } + public Domain.Stores.Store Store { get; set; } + public string ApprovalId { get; set; } + public bool Approve { get; set; } + public string Comment { get; set; } + public Language Language { get; set; } + public DomainHost Host { get; set; } +} diff --git a/src/Web/Grand.Web/Commands/Models/Orders/SubmitOrderApprovalCommand.cs b/src/Web/Grand.Web/Commands/Models/Orders/SubmitOrderApprovalCommand.cs new file mode 100644 index 0000000000..2162eead7f --- /dev/null +++ b/src/Web/Grand.Web/Commands/Models/Orders/SubmitOrderApprovalCommand.cs @@ -0,0 +1,20 @@ +using Grand.Business.Core.Utilities.Checkout; +using Grand.Domain.Customers; +using Grand.Domain.Localization; +using Grand.Domain.Stores; +using Grand.Mediator; + +namespace Grand.Web.Commands.Models.Orders; + +/// +/// Asks the company's approvers to approve the buyer's current cart. Returns the refusal as a translated +/// message when the cart needs no approval. +/// +public class SubmitOrderApprovalCommand : IRequest<(bool success, string error)> +{ + public Customer Customer { get; set; } + public Domain.Stores.Store Store { get; set; } + public Language Language { get; set; } + public CalculationContext Context { get; set; } + public DomainHost Host { get; set; } +} diff --git a/src/Web/Grand.Web/Commands/Models/Orders/WithdrawOrderApprovalCommand.cs b/src/Web/Grand.Web/Commands/Models/Orders/WithdrawOrderApprovalCommand.cs new file mode 100644 index 0000000000..1363910537 --- /dev/null +++ b/src/Web/Grand.Web/Commands/Models/Orders/WithdrawOrderApprovalCommand.cs @@ -0,0 +1,14 @@ +using Grand.Domain.Customers; +using Grand.Mediator; + +namespace Grand.Web.Commands.Models.Orders; + +/// +/// Withdraws the buyer's own pending or approved order approval +/// +public class WithdrawOrderApprovalCommand : IRequest +{ + public Customer Customer { get; set; } + public Domain.Stores.Store Store { get; set; } + public string ApprovalId { get; set; } +} From 6e5d947c2ccf329aec748e4a95143e9657c0aaa8 Mon Sep 17 00:00:00 2001 From: support Date: Sun, 25 Oct 2026 12:44:02 +0100 Subject: [PATCH 12/28] Show order approvals at the confirm step and in My account --- .../OrderApprovalControllerTests.cs | 276 ++++++++++++++++++ .../GetConfirmOrderHandlerApprovalTests.cs | 139 +++++++++ .../Customers/GetNavigationHandlerTests.cs | 54 +++- .../Orders/GetOrderApprovalHandlersTests.cs | 214 ++++++++++++++ .../GetShoppingCartHandlerApprovalTests.cs | 162 ++++++++++ .../Checkout/CheckoutConfirmModel.cs | 27 ++ .../Customer/CustomerNavigationModel.cs | 2 + .../Orders/OrderApprovalListModel.cs | 31 ++ .../Orders/OrderApprovalModel.cs | 48 +++ .../ShoppingCart/ShoppingCartModel.cs | 5 + .../Controllers/CheckoutController.cs | 10 +- .../Controllers/OrderApprovalController.cs | 156 ++++++++++ .../Grand.Web/Endpoints/EndpointProvider.cs | 20 ++ .../Checkout/GetConfirmOrderHandler.cs | 48 ++- .../Customers/GetNavigationHandler.cs | 23 +- .../Orders/GetOrderApprovalHandler.cs | 91 ++++++ .../Orders/GetOrderApprovalsHandler.cs | 73 +++++ .../Handlers/Orders/OrderApprovalBuyerName.cs | 21 ++ .../ShoppingCart/GetShoppingCartHandler.cs | 39 ++- .../Models/Checkout/GetConfirmOrder.cs | 7 +- .../Models/Orders/GetOrderApproval.cs | 18 ++ .../Models/Orders/GetOrderApprovals.cs | 14 + .../Checkout/Partials/ConfirmOrder.cshtml | 28 +- .../Views/OrderApproval/Details.cshtml | 139 +++++++++ .../Grand.Web/Views/OrderApproval/List.cshtml | 66 +++++ .../CustomerNavigation/Default.cshtml | 8 + .../ShoppingCart/Partials/CartSummary.cshtml | 3 + .../wwwroot/theme/script/public.checkout.js | 2 + 28 files changed, 1710 insertions(+), 14 deletions(-) create mode 100644 src/Tests/Grand.Web.Tests/Controllers/OrderApprovalControllerTests.cs create mode 100644 src/Tests/Grand.Web.Tests/Features/Checkout/GetConfirmOrderHandlerApprovalTests.cs create mode 100644 src/Tests/Grand.Web.Tests/Features/Handlers/Orders/GetOrderApprovalHandlersTests.cs create mode 100644 src/Tests/Grand.Web.Tests/Features/ShoppingCart/GetShoppingCartHandlerApprovalTests.cs create mode 100644 src/Web/Grand.Web.Models/Orders/OrderApprovalListModel.cs create mode 100644 src/Web/Grand.Web.Models/Orders/OrderApprovalModel.cs create mode 100644 src/Web/Grand.Web/Controllers/OrderApprovalController.cs create mode 100644 src/Web/Grand.Web/Features/Handlers/Orders/GetOrderApprovalHandler.cs create mode 100644 src/Web/Grand.Web/Features/Handlers/Orders/GetOrderApprovalsHandler.cs create mode 100644 src/Web/Grand.Web/Features/Handlers/Orders/OrderApprovalBuyerName.cs create mode 100644 src/Web/Grand.Web/Features/Models/Orders/GetOrderApproval.cs create mode 100644 src/Web/Grand.Web/Features/Models/Orders/GetOrderApprovals.cs create mode 100644 src/Web/Grand.Web/Views/OrderApproval/Details.cshtml create mode 100644 src/Web/Grand.Web/Views/OrderApproval/List.cshtml diff --git a/src/Tests/Grand.Web.Tests/Controllers/OrderApprovalControllerTests.cs b/src/Tests/Grand.Web.Tests/Controllers/OrderApprovalControllerTests.cs new file mode 100644 index 0000000000..dca09a0d93 --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Controllers/OrderApprovalControllerTests.cs @@ -0,0 +1,276 @@ +using Grand.Domain.Customers; +using Grand.Domain.Directory; +using Grand.Domain.Localization; +using Grand.Domain.Tax; +using Grand.Infrastructure; +using Grand.Mediator; +using Grand.Web.Commands.Models.Orders; +using Grand.Web.Common.Localization; +using Grand.Web.Controllers; +using Grand.Web.Features.Models.Orders; +using Grand.Web.Models.Orders; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Tests.Controllers; + +[TestClass] +public class OrderApprovalControllerTests +{ + private Customer _customer; + private CustomerSettings _customerSettings; + private Grand.Domain.Stores.Store _store; + private Currency _currency; + private Mock _mediator; + private OrderApprovalController _controller; + + [TestInitialize] + public void Init() + { + _customer = new Customer { Id = "buyer", OwnerId = "owner" }; + _store = new Grand.Domain.Stores.Store { Id = "s1" }; + _currency = new Currency { Id = "c1", CurrencyCode = "EUR" }; + + var work = new Mock(); + work.Setup(w => w.CurrentCustomer).Returns(() => _customer); + work.Setup(w => w.WorkingLanguage).Returns(new Language { Id = "en" }); + work.Setup(w => w.WorkingCurrency).Returns(() => _currency); + work.Setup(w => w.TaxDisplayType).Returns(TaxDisplayType.ExcludingTax); + var storeContext = new Mock(); + storeContext.Setup(s => s.CurrentStore).Returns(() => _store); + storeContext.Setup(s => s.CurrentHost).Returns(new Grand.Domain.Stores.DomainHost { HostName = "shop.test" }); + var context = new Mock(); + context.Setup(c => c.WorkContext).Returns(work.Object); + context.Setup(c => c.StoreContext).Returns(storeContext.Object); + + var translation = new Mock(); + translation.Setup(t => t.GetResource(It.IsAny())).Returns((string key) => key); + + _mediator = new Mock(); + _customerSettings = new CustomerSettings(); + _controller = new OrderApprovalController(context.Object, _mediator.Object, translation.Object, + _customerSettings) { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + _controller.TempData = new TempDataDictionary(_controller.HttpContext, new Mock().Object); + } + + private static void AssertRedirectTo(IActionResult result, string routeName) + { + Assert.IsInstanceOfType(result); + Assert.AreEqual(routeName, ((RedirectToRouteResult)result).RouteName); + } + + private IList Notifications(string type) + { + return _controller.TempData[$"grand.notifications.{type}"] as IList ?? []; + } + + //the feature switch + + [TestMethod] + public async Task List_FeatureOff_RedirectsToCustomerInfo() + { + _customerSettings.HideSubAccountsTab = true; + + var result = await _controller.List(new OrderPagingModel()); + + AssertRedirectTo(result, "CustomerInfo"); + _mediator.Verify(m => m.Send(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task EveryAction_FeatureOff_RedirectsToCustomerInfoAndDoesNothing() + { + _customerSettings.HideSubAccountsTab = true; + + AssertRedirectTo(await _controller.Details("a1"), "CustomerInfo"); + AssertRedirectTo(await _controller.Submit(), "CustomerInfo"); + AssertRedirectTo(await _controller.Decide("a1", true, null), "CustomerInfo"); + AssertRedirectTo(await _controller.Withdraw("a1", false), "CustomerInfo"); + + _mediator.Verify(m => m.Send(It.IsAny(), It.IsAny()), + Times.Never); + _mediator.Verify(m => m.Send(It.IsAny(), It.IsAny()), + Times.Never); + _mediator.Verify(m => m.Send(It.IsAny(), It.IsAny()), + Times.Never); + _mediator.Verify(m => m.Send(It.IsAny(), It.IsAny()), Times.Never); + } + + //list + + [TestMethod] + public async Task List_FeatureOn_ReturnsTheModel() + { + var model = new OrderApprovalListModel(); + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(model); + + var result = await _controller.List(new OrderPagingModel { PageNumber = 2 }); + + Assert.IsInstanceOfType(result); + Assert.AreSame(model, ((ViewResult)result).Model); + _mediator.Verify(m => m.Send(It.Is(q => + q.Customer == _customer && q.Store == _store && q.Command.PageNumber == 2), It.IsAny())); + } + + //details + + [TestMethod] + public async Task Details_OtherCompany_RedirectsToList() + { + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync((OrderApprovalModel)null); + + var result = await _controller.Details("foreign"); + + AssertRedirectTo(result, "CustomerOrderApprovals"); + _mediator.Verify(m => m.Send(It.Is(q => + q.Id == "foreign" && q.Customer == _customer && q.Store == _store), It.IsAny())); + } + + [TestMethod] + public async Task Details_Found_ReturnsTheModel() + { + var model = new OrderApprovalModel { Id = "a1" }; + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(model); + + var result = await _controller.Details("a1"); + + Assert.IsInstanceOfType(result); + Assert.AreSame(model, ((ViewResult)result).Model); + } + + //submit + + [TestMethod] + public async Task Submit_ReturnsToCartOnSuccess() + { + SubmitOrderApprovalCommand sent = null; + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .Callback, CancellationToken>((c, _) => + sent = (SubmitOrderApprovalCommand)c) + .ReturnsAsync((true, (string)null)); + + var result = await _controller.Submit(); + + AssertRedirectTo(result, "ShoppingCart"); + Assert.AreEqual(0, Notifications("Error").Count); + Assert.AreEqual(1, Notifications("Success").Count); + Assert.IsNotNull(sent); + Assert.AreSame(_customer, sent.Customer); + Assert.AreSame(_store, sent.Store); + Assert.AreEqual("shop.test", sent.Host.HostName); + //the calculation context comes from the request, never from posted data + Assert.AreSame(_customer, sent.Context.Customer); + Assert.AreSame(_store, sent.Context.Store); + Assert.AreSame(_currency, sent.Context.Currency); + } + + [TestMethod] + public async Task Submit_Refused_ShowsTheErrorAndReturnsToCart() + { + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync((false, "OrderApproval.Submit.NotNeeded")); + + var result = await _controller.Submit(); + + AssertRedirectTo(result, "ShoppingCart"); + CollectionAssert.AreEqual(new[] { "OrderApproval.Submit.NotNeeded" }, Notifications("Error").ToArray()); + Assert.AreEqual(0, Notifications("Success").Count); + } + + //decide + + [TestMethod] + public async Task Decide_Refused_ShowsError() + { + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + var result = await _controller.Decide("a1", true, "ok"); + + AssertRedirectTo(result, "CustomerOrderApprovals"); + CollectionAssert.AreEqual(new[] { "OrderApproval.Decide.Refused" }, Notifications("Error").ToArray()); + Assert.AreEqual(0, Notifications("Success").Count); + } + + [TestMethod] + public async Task Decide_Approved_SendsTheDecisionOfTheCurrentCustomer() + { + DecideOrderApprovalCommand sent = null; + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .Callback, CancellationToken>((c, _) => sent = (DecideOrderApprovalCommand)c) + .ReturnsAsync(true); + + var result = await _controller.Decide("a1", true, "go ahead"); + + AssertRedirectTo(result, "CustomerOrderApprovals"); + CollectionAssert.AreEqual(new[] { "OrderApproval.Decide.Approved" }, Notifications("Success").ToArray()); + Assert.AreSame(_customer, sent.Approver); + Assert.AreSame(_store, sent.Store); + Assert.AreEqual("a1", sent.ApprovalId); + Assert.IsTrue(sent.Approve); + Assert.AreEqual("go ahead", sent.Comment); + Assert.AreEqual("shop.test", sent.Host.HostName); + Assert.AreEqual("en", sent.Language.Id); + } + + [TestMethod] + public async Task Decide_Rejected_SaysRejected() + { + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + await _controller.Decide("a1", false, null); + + CollectionAssert.AreEqual(new[] { "OrderApproval.Decide.Rejected" }, Notifications("Success").ToArray()); + _mediator.Verify(m => m.Send(It.Is(c => !c.Approve), + It.IsAny())); + } + + //withdraw + + [TestMethod] + public async Task Withdraw_FromTheConfirmStep_ReturnsToCart() + { + WithdrawOrderApprovalCommand sent = null; + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .Callback, CancellationToken>((c, _) => sent = (WithdrawOrderApprovalCommand)c) + .ReturnsAsync(true); + + var result = await _controller.Withdraw("a1", true); + + AssertRedirectTo(result, "ShoppingCart"); + Assert.AreEqual(1, Notifications("Success").Count); + Assert.AreSame(_customer, sent.Customer); + Assert.AreSame(_store, sent.Store); + Assert.AreEqual("a1", sent.ApprovalId); + } + + [TestMethod] + public async Task Withdraw_FromTheList_ReturnsToTheList() + { + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + AssertRedirectTo(await _controller.Withdraw("a1", false), "CustomerOrderApprovals"); + } + + [TestMethod] + public async Task Withdraw_Refused_ShowsError() + { + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + var result = await _controller.Withdraw("a1", true); + + AssertRedirectTo(result, "ShoppingCart"); + CollectionAssert.AreEqual(new[] { "OrderApproval.Withdraw.Refused" }, Notifications("Error").ToArray()); + } +} diff --git a/src/Tests/Grand.Web.Tests/Features/Checkout/GetConfirmOrderHandlerApprovalTests.cs b/src/Tests/Grand.Web.Tests/Features/Checkout/GetConfirmOrderHandlerApprovalTests.cs new file mode 100644 index 0000000000..40163345f3 --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Features/Checkout/GetConfirmOrderHandlerApprovalTests.cs @@ -0,0 +1,139 @@ +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.Utilities.Checkout; +using Grand.Domain.Customers; +using Grand.Domain.Directory; +using Grand.Domain.Localization; +using Grand.Domain.Orders; +using Grand.Domain.Shipping; +using Grand.Domain.Tax; +using Grand.Mediator; +using Grand.Web.Common.Localization; +using Grand.Web.Features.Handlers.Checkout; +using Grand.Web.Features.Models.Checkout; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Tests.Features.Checkout; + +[TestClass] +public class GetConfirmOrderHandlerApprovalTests +{ + private static readonly DateTime Created = new(2026, 10, 20, 8, 30, 0, DateTimeKind.Utc); + private static readonly DateTime CreatedForUser = new(2026, 10, 20, 10, 30, 0); + + private Mock _approvals; + private Customer _customer; + private GetConfirmOrderHandler _handler; + + [TestInitialize] + public void Init() + { + _customer = new Customer { Id = "buyer", OwnerId = "owner" }; + _approvals = new Mock(); + + var dates = new Mock(); + dates.Setup(d => d.ConvertToUserTime(Created, DateTimeKind.Utc)).Returns(CreatedForUser); + var translation = new Mock(); + translation.Setup(t => t.GetResource(It.IsAny())).Returns((string key) => key); + + _handler = new GetConfirmOrderHandler(new Mock().Object, + new Mock().Object, new Mock().Object, new OrderSettings(), + new ShippingSettings(), _approvals.Object, dates.Object, translation.Object); + } + + private void State(bool required, OrderApproval active = null) + { + _approvals.Setup(a => a.GetCheckoutState(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new OrderApprovalCheckoutState(required, 100, required ? 250 : 50, active)); + } + + private Task Confirm() + { + return _handler.Handle(new GetConfirmOrder { + Customer = _customer, + Store = new Grand.Domain.Stores.Store { Id = "s1" }, + Language = new Language { Id = "en" }, + Currency = new Currency { Id = "c1" }, + TaxDisplayType = TaxDisplayType.ExcludingTax, + Cart = new List() + }, CancellationToken.None); + } + + [TestMethod] + public async Task Confirm_Blocked_SetsRequiredAndMessage() + { + State(true); + + var model = await Confirm(); + + Assert.IsNotNull(model.OrderApproval); + Assert.IsTrue(model.OrderApproval.Required); + Assert.IsFalse(model.OrderApproval.Pending); + Assert.IsFalse(model.OrderApproval.Approved); + Assert.AreEqual("OrderApproval.Confirm.Required", model.OrderApproval.Message); + Assert.IsNull(model.OrderApproval.PendingSince); + Assert.IsNull(model.OrderApproval.ApprovalId); + } + + [TestMethod] + public async Task Confirm_NotInCompany_NoApprovalState() + { + _customer.OwnerId = null; + State(false); + + var model = await Confirm(); + + Assert.IsNull(model.OrderApproval); + } + + [TestMethod] + public async Task Confirm_UnderTheLimit_NoApprovalState() + { + State(false); + + Assert.IsNull((await Confirm()).OrderApproval); + } + + [TestMethod] + public async Task Confirm_Pending_SetsPendingSince() + { + State(true, new OrderApproval { Id = "a1", Status = OrderApprovalStatus.Pending, CreatedOnUtc = Created }); + + var model = await Confirm(); + + Assert.IsTrue(model.OrderApproval.Required); + Assert.IsTrue(model.OrderApproval.Pending); + Assert.IsFalse(model.OrderApproval.Approved); + Assert.AreEqual("a1", model.OrderApproval.ApprovalId); + Assert.AreEqual("OrderApproval.Confirm.Pending", model.OrderApproval.Message); + Assert.AreEqual(CreatedForUser.ToString("g"), model.OrderApproval.PendingSince); + } + + [TestMethod] + public async Task Confirm_Approved_LetsTheOrderThrough() + { + State(true, new OrderApproval { Id = "a2", Status = OrderApprovalStatus.Approved, CreatedOnUtc = Created }); + + var model = await Confirm(); + + Assert.IsTrue(model.OrderApproval.Required); + Assert.IsTrue(model.OrderApproval.Approved); + Assert.IsFalse(model.OrderApproval.Pending); + Assert.AreEqual("a2", model.OrderApproval.ApprovalId); + Assert.IsNull(model.OrderApproval.PendingSince); + } + + [TestMethod] + public async Task Confirm_AskesTheStateOfTheRequestsCustomerAndStore() + { + State(false); + + await Confirm(); + + _approvals.Verify(a => a.GetCheckoutState(It.IsAny>(), + It.Is(c => c.Customer == _customer && c.Store.Id == "s1" && + c.Currency.Id == "c1" && c.TaxDisplayType == TaxDisplayType.ExcludingTax))); + } +} diff --git a/src/Tests/Grand.Web.Tests/Features/Handlers/Customers/GetNavigationHandlerTests.cs b/src/Tests/Grand.Web.Tests/Features/Handlers/Customers/GetNavigationHandlerTests.cs index 564268b958..13eb166ff3 100644 --- a/src/Tests/Grand.Web.Tests/Features/Handlers/Customers/GetNavigationHandlerTests.cs +++ b/src/Tests/Grand.Web.Tests/Features/Handlers/Customers/GetNavigationHandlerTests.cs @@ -1,3 +1,5 @@ +using Grand.Business.Core.Interfaces.Customers; +using Grand.Domain; using Grand.Domain.Customers; using Grand.Domain.Orders; using Grand.Domain.Vendors; @@ -5,16 +7,31 @@ using Grand.Web.Features.Handlers.Customers; using Grand.Web.Features.Models.Customers; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; namespace Grand.Web.Tests.Features.Handlers.Customers; [TestClass] public class GetNavigationHandlerTests { + private static Mock CustomerServiceWith(int subAccounts) + { + var customers = new Mock(); + customers.Setup(c => c.GetAllCustomers(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>>(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new PagedList([], 0, 1, subAccounts)); + return customers; + } + private static async Task HideChangePassword(OidcLoginConfig config) { var handler = new GetNavigationHandler(new CustomerSettings(), new LoyaltyPointsSettings(), - new OrderSettings(), new VendorSettings(), config); + new OrderSettings(), new VendorSettings(), config, CustomerServiceWith(0).Object); var model = await handler.Handle(new GetNavigation { Customer = new Customer() }, CancellationToken.None); return model.HideChangePassword; } @@ -30,4 +47,39 @@ public async Task Navigation_Mixed_ShowsChangePassword() { Assert.IsFalse(await HideChangePassword(new OidcLoginConfig())); } + + private static async Task HideOrderApprovals(Customer customer, int subAccounts, + bool featureOff = false) + { + var customers = CustomerServiceWith(subAccounts); + var handler = new GetNavigationHandler(new CustomerSettings { HideSubAccountsTab = featureOff }, + new LoyaltyPointsSettings(), new OrderSettings(), new VendorSettings(), new OidcLoginConfig(), + customers.Object); + var model = await handler.Handle(new GetNavigation { Customer = customer }, CancellationToken.None); + return model.HideOrderApprovals; + } + + [TestMethod] + public async Task Navigation_SubAccount_ShowsOrderApprovals() + { + Assert.IsFalse(await HideOrderApprovals(new Customer { Id = "sub", OwnerId = "owner" }, 0)); + } + + [TestMethod] + public async Task Navigation_OwnerWithSubAccounts_ShowsOrderApprovals() + { + Assert.IsFalse(await HideOrderApprovals(new Customer { Id = "owner" }, 2)); + } + + [TestMethod] + public async Task Navigation_OwnerWithoutSubAccounts_HidesOrderApprovals() + { + Assert.IsTrue(await HideOrderApprovals(new Customer { Id = "owner" }, 0)); + } + + [TestMethod] + public async Task Navigation_FeatureOff_HidesOrderApprovals() + { + Assert.IsTrue(await HideOrderApprovals(new Customer { Id = "sub", OwnerId = "owner" }, 0, true)); + } } diff --git a/src/Tests/Grand.Web.Tests/Features/Handlers/Orders/GetOrderApprovalHandlersTests.cs b/src/Tests/Grand.Web.Tests/Features/Handlers/Orders/GetOrderApprovalHandlersTests.cs new file mode 100644 index 0000000000..81f17628e7 --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Features/Handlers/Orders/GetOrderApprovalHandlersTests.cs @@ -0,0 +1,214 @@ +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Domain; +using Grand.Domain.Customers; +using Grand.Domain.Orders; +using Grand.Web.Common.Localization; +using Grand.Web.Features.Handlers.Orders; +using Grand.Web.Features.Models.Orders; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Tests.Features.Handlers.Orders; + +[TestClass] +public class GetOrderApprovalHandlersTests +{ + private Mock _approvals; + private Mock _customers; + private GetOrderApprovalHandler _detailsHandler; + private GetOrderApprovalsHandler _listHandler; + private Grand.Domain.Stores.Store _store; + + [TestInitialize] + public void Init() + { + _store = new Grand.Domain.Stores.Store { Id = "s1" }; + _approvals = new Mock(); + _customers = new Mock(); + var dates = new Mock(); + dates.Setup(d => d.ConvertToUserTime(It.IsAny(), DateTimeKind.Utc)) + .Returns((DateTime d, DateTimeKind _) => d); + var enums = new Mock(); + enums.Setup(e => e.GetTranslationEnum(It.IsAny())) + .Returns((OrderApprovalStatus s) => s.ToString()); + _detailsHandler = new GetOrderApprovalHandler(_approvals.Object, _customers.Object, dates.Object, + enums.Object); + _listHandler = new GetOrderApprovalsHandler(_approvals.Object, _customers.Object, dates.Object, + enums.Object); + } + + private static OrderApproval Approval(string id = "a1", string buyer = "buyer", + OrderApprovalStatus status = OrderApprovalStatus.Pending) + { + return new OrderApproval { + Id = id, CustomerId = buyer, CompanyOwnerId = "owner", StoreId = "s1", Status = status, + Items = [new OrderApprovalItem { ProductName = "Desk {{ 1 + 1 }}", Quantity = 2 }], + Summary = new OrderApprovalSummary { Total = "300 EUR" } + }; + } + + private Task Details(Customer who, string id = "a1") + { + return _detailsHandler.Handle(new GetOrderApproval { Customer = who, Store = _store, Id = id }, + CancellationToken.None); + } + + private void Found(OrderApproval approval) + { + _approvals.Setup(a => a.GetById(approval.Id, "owner")).ReturnsAsync(approval); + } + + //details + + [TestMethod] + public async Task Details_AskedWithTheViewersCompany() + { + var model = await Details(new Customer { Id = "someone", OwnerId = "other-owner" }); + + Assert.IsNull(model); + _approvals.Verify(a => a.GetById("a1", "other-owner")); + } + + [TestMethod] + public async Task Details_OtherStore_IsNull() + { + var approval = Approval(); + approval.StoreId = "s2"; + Found(approval); + + Assert.IsNull(await Details(new Customer { Id = "owner" })); + } + + [TestMethod] + public async Task Details_BuyerSeesOwnRequest_AndCanWithdrawIt() + { + Found(Approval()); + + var model = await Details(new Customer { Id = "buyer", OwnerId = "owner" }); + + Assert.IsNotNull(model); + Assert.IsTrue(model.CanWithdraw); + Assert.IsFalse(model.CanDecide); + Assert.AreEqual(1, model.Items.Count); + Assert.AreEqual("300 EUR", model.Summary.Total); + Assert.AreEqual("Pending", model.Status); + } + + [TestMethod] + public async Task Details_ColleagueWithoutApproverRole_IsNull() + { + Found(Approval()); + + var model = await Details(new Customer { Id = "colleague", OwnerId = "owner", SubAccountRole = SubAccountRole.Buyer }); + + Assert.IsNull(model); + } + + [TestMethod] + public async Task Details_OwnerSeesCompanyRequest_AndCanDecideIt() + { + Found(Approval()); + _customers.Setup(c => c.GetCustomerById("buyer")) + .ReturnsAsync(new Customer { Id = "buyer", Email = "b@x.test" }); + + var model = await Details(new Customer { Id = "owner" }); + + Assert.IsTrue(model.CanDecide); + Assert.IsFalse(model.CanWithdraw); + Assert.AreEqual("b@x.test", model.Buyer); + } + + [TestMethod] + public async Task Details_DecidedRequest_CannotBeDecidedAgain() + { + Found(Approval(status: OrderApprovalStatus.Rejected)); + + var model = await Details(new Customer { Id = "owner" }); + + Assert.IsFalse(model.CanDecide); + } + + [TestMethod] + public async Task Details_UsedRequest_CannotBeWithdrawn() + { + Found(Approval(status: OrderApprovalStatus.Used)); + + var model = await Details(new Customer { Id = "buyer", OwnerId = "owner" }); + + Assert.IsFalse(model.CanWithdraw); + } + + //list + + private static IPagedList Page(params OrderApproval[] items) + { + return new PagedList(items, 0, 10, items.Length); + } + + [TestMethod] + public async Task List_OwnerGetsTheCompanyList() + { + _approvals.Setup(a => a.GetForCompany("owner", "s1", 0, 10)).ReturnsAsync(Page(Approval())); + _customers.Setup(c => c.GetCustomersByIds(It.IsAny())) + .ReturnsAsync([new Customer { Id = "buyer", Email = "b@x.test" }]); + + var model = await _listHandler.Handle(new GetOrderApprovals { + Customer = new Customer { Id = "owner" }, Store = _store + }, CancellationToken.None); + + Assert.IsTrue(model.IsCompanyList); + Assert.AreEqual(1, model.Approvals.Count); + Assert.AreEqual("b@x.test", model.Approvals[0].Buyer); + Assert.AreEqual("300 EUR", model.Approvals[0].Total); + Assert.AreEqual(1, model.Approvals[0].ItemCount); + Assert.IsTrue(model.Approvals[0].IsPending); + _approvals.Verify(a => a.GetForCustomer(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task List_ApproverSubAccountGetsTheCompanyListOfItsOwner() + { + _approvals.Setup(a => a.GetForCompany("owner", "s1", 0, 10)).ReturnsAsync(Page()); + + var model = await _listHandler.Handle(new GetOrderApprovals { + Customer = new Customer { Id = "appr", OwnerId = "owner", SubAccountRole = SubAccountRole.Approver }, + Store = _store + }, CancellationToken.None); + + Assert.IsTrue(model.IsCompanyList); + _approvals.Verify(a => a.GetForCompany("owner", "s1", 0, 10)); + } + + [TestMethod] + public async Task List_BuyerGetsOnlyOwnRequests() + { + _approvals.Setup(a => a.GetForCustomer("buyer", "s1", 1, 10)).ReturnsAsync(Page(Approval())); + + var model = await _listHandler.Handle(new GetOrderApprovals { + Customer = new Customer { Id = "buyer", OwnerId = "owner" }, Store = _store, + Command = new Grand.Web.Models.Orders.OrderPagingModel { PageNumber = 2 } + }, CancellationToken.None); + + Assert.IsFalse(model.IsCompanyList); + Assert.IsNull(model.Approvals[0].Buyer); + _approvals.Verify(a => a.GetForCompany(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + _customers.Verify(c => c.GetCustomersByIds(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task List_PageSizeIsCapped() + { + _approvals.Setup(a => a.GetForCompany("owner", "s1", 0, 50)).ReturnsAsync(Page()); + + await _listHandler.Handle(new GetOrderApprovals { + Customer = new Customer { Id = "owner" }, Store = _store, + Command = new Grand.Web.Models.Orders.OrderPagingModel { PageSize = 100000 } + }, CancellationToken.None); + + _approvals.Verify(a => a.GetForCompany("owner", "s1", 0, 50)); + } +} diff --git a/src/Tests/Grand.Web.Tests/Features/ShoppingCart/GetShoppingCartHandlerApprovalTests.cs b/src/Tests/Grand.Web.Tests/Features/ShoppingCart/GetShoppingCartHandlerApprovalTests.cs new file mode 100644 index 0000000000..ae4afd5d7b --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Features/ShoppingCart/GetShoppingCartHandlerApprovalTests.cs @@ -0,0 +1,162 @@ +using Grand.Business.Core.Interfaces.Catalog.Discounts; +using Grand.Business.Core.Interfaces.Catalog.Prices; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Catalog.Tax; +using Grand.Business.Core.Interfaces.Checkout.CheckoutAttributes; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Checkout.Shipping; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.Storage; +using Grand.Business.Core.Utilities.Catalog; +using Grand.Business.Core.Utilities.Checkout; +using Grand.Domain.Catalog; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Domain.Directory; +using Grand.Domain.Localization; +using Grand.Domain.Media; +using Grand.Domain.Orders; +using Grand.Domain.Stores; +using Grand.Domain.Tax; +using Grand.Infrastructure.Configuration; +using Grand.Mediator; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Grand.Web.Features.Handlers.ShoppingCart; +using Grand.Web.Features.Models.ShoppingCart; +using Grand.Web.Models.ShoppingCart; +using Microsoft.AspNetCore.Routing; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Tests.Features.ShoppingCart; + +[TestClass] +public class GetShoppingCartHandlerApprovalTests +{ + private static readonly DateTime Created = new(2026, 10, 20, 8, 30, 0, DateTimeKind.Utc); + private static readonly DateTime CreatedForUser = new(2026, 10, 20, 10, 30, 0); + + private Mock _approvals; + private CustomerSettings _customerSettings; + private Customer _customer; + private List _cart; + private GetShoppingCartHandler _handler; + + [TestInitialize] + public void Init() + { + _customer = new Customer { Id = "buyer", OwnerId = "owner" }; + _cart = [new ShoppingCartItem { + Id = "i1", ProductId = "p1", Quantity = 2, ShoppingCartTypeId = ShoppingCartType.ShoppingCart + }]; + _approvals = new Mock(); + _customerSettings = new CustomerSettings(); + + var dates = new Mock(); + dates.Setup(d => d.ConvertToUserTime(Created, DateTimeKind.Utc)).Returns(CreatedForUser); + var translation = new Mock(); + translation.Setup(t => t.GetResource(It.IsAny())).Returns((string key) => key + "|{0}"); + var checkoutAttributes = new Mock(); + checkoutAttributes.Setup(c => c.GetAllCheckoutAttributes(It.IsAny(), It.IsAny())) + .ReturnsAsync([]); + var validator = new Mock(); + validator.Setup(v => v.GetShoppingCartWarnings(It.IsAny>(), + It.IsAny>(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync([]); + var groups = new Mock(); + + _handler = new GetShoppingCartHandler(new Mock().Object, + new Mock().Object, translation.Object, new Mock().Object, + new Mock().Object, new Mock().Object, checkoutAttributes.Object, + new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, dates.Object, + new Mock().Object, groups.Object, new Mock().Object, validator.Object, + new Mock().Object, new Mock().Object, new MediaSettings(), + new OrderSettings(), new OidcLoginConfig(), new ShoppingCartSettings(), new CatalogSettings(), + new CommonSettings(), new Mock().Object, _approvals.Object, _customerSettings); + } + + private Task Cart() + { + return _handler.Handle(new GetShoppingCart { + Customer = _customer, + Store = new Grand.Domain.Stores.Store { Id = "s1" }, + Language = new Language { Id = "en" }, + Currency = new Currency { Id = "c1" }, + Host = new DomainHost(), + Cart = _cart + }, CancellationToken.None); + } + + [TestMethod] + public async Task Cart_PendingApproval_ShowsNote() + { + _approvals.Setup(a => a.GetActive("buyer", "s1")).ReturnsAsync(new OrderApproval { + Status = OrderApprovalStatus.Pending, CreatedOnUtc = Created + }); + + var model = await Cart(); + + StringAssert.Contains(model.OrderApprovalNote, CreatedForUser.ToString("g")); + StringAssert.StartsWith(model.OrderApprovalNote, "OrderApproval.Cart.Pending"); + } + + [TestMethod] + public async Task Cart_ApprovedForThisCart_ShowsApprovedNote() + { + _approvals.Setup(a => a.GetActive("buyer", "s1")).ReturnsAsync(new OrderApproval { + Status = OrderApprovalStatus.Approved, CreatedOnUtc = Created, + CartFingerprint = OrderApprovalFingerprint.Compute(_cart) + }); + + var model = await Cart(); + + StringAssert.StartsWith(model.OrderApprovalNote, "OrderApproval.Cart.Approved"); + } + + [TestMethod] + public async Task Cart_ApprovedForAnotherCart_NoNote() + { + _approvals.Setup(a => a.GetActive("buyer", "s1")).ReturnsAsync(new OrderApproval { + Status = OrderApprovalStatus.Approved, CreatedOnUtc = Created, CartFingerprint = "other" + }); + + Assert.IsNull((await Cart()).OrderApprovalNote); + } + + [TestMethod] + public async Task Cart_NoApproval_NoNote() + { + _approvals.Setup(a => a.GetActive(It.IsAny(), It.IsAny())) + .ReturnsAsync((OrderApproval)null); + + Assert.IsNull((await Cart()).OrderApprovalNote); + } + + [TestMethod] + public async Task Cart_NotACompanyBuyer_NoNoteAndNoLookup() + { + _customer.OwnerId = null; + + var model = await Cart(); + + Assert.IsNull(model.OrderApprovalNote); + _approvals.Verify(a => a.GetActive(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Cart_FeatureOff_NoNoteAndNoLookup() + { + _customerSettings.HideSubAccountsTab = true; + + var model = await Cart(); + + Assert.IsNull(model.OrderApprovalNote); + _approvals.Verify(a => a.GetActive(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/src/Web/Grand.Web.Models/Checkout/CheckoutConfirmModel.cs b/src/Web/Grand.Web.Models/Checkout/CheckoutConfirmModel.cs index 31ddfc1310..730589fd55 100644 --- a/src/Web/Grand.Web.Models/Checkout/CheckoutConfirmModel.cs +++ b/src/Web/Grand.Web.Models/Checkout/CheckoutConfirmModel.cs @@ -8,9 +8,36 @@ public class CheckoutConfirmModel : BaseModel public bool TermsOfServiceOnOrderConfirmPage { get; set; } public string MinOrderTotalWarning { get; set; } + /// + /// The order approval state of the cart; null when the order needs no approval + /// + public OrderApprovalStateModel OrderApproval { get; set; } + public IList Warnings { get; set; } = new List(); public OrderReviewDataModel OrderReviewData { get; set; } = new(); + public class OrderApprovalStateModel : BaseModel + { + /// + /// The order total is over the customer's limit, so an approval is needed + /// + public bool Required { get; set; } + + /// + /// A request is waiting for a decision + /// + public bool Pending { get; set; } + + /// + /// The cart is approved; the order can be placed + /// + public bool Approved { get; set; } + + public string Message { get; set; } + public string PendingSince { get; set; } + public string ApprovalId { get; set; } + } + public class OrderReviewDataModel : BaseModel { public AddressModel BillingAddress { get; set; } = new(); diff --git a/src/Web/Grand.Web.Models/Customer/CustomerNavigationModel.cs b/src/Web/Grand.Web.Models/Customer/CustomerNavigationModel.cs index 5a1a474c55..994b9bae92 100644 --- a/src/Web/Grand.Web.Models/Customer/CustomerNavigationModel.cs +++ b/src/Web/Grand.Web.Models/Customer/CustomerNavigationModel.cs @@ -19,6 +19,7 @@ public class CustomerNavigationModel : BaseModel public bool HideReviews { get; set; } public bool HideCourses { get; set; } public bool HideSubAccounts { get; set; } + public bool HideOrderApprovals { get; set; } public AccountNavigationEnum SelectedTab { get; set; } } @@ -40,5 +41,6 @@ public enum AccountNavigationEnum Reviews = 140, Courses = 150, SubAccounts = 160, + OrderApprovals = 165, Others = 900 } \ No newline at end of file diff --git a/src/Web/Grand.Web.Models/Orders/OrderApprovalListModel.cs b/src/Web/Grand.Web.Models/Orders/OrderApprovalListModel.cs new file mode 100644 index 0000000000..f11858b152 --- /dev/null +++ b/src/Web/Grand.Web.Models/Orders/OrderApprovalListModel.cs @@ -0,0 +1,31 @@ +using Grand.Infrastructure.Models; + +namespace Grand.Web.Models.Orders; + +public class OrderApprovalListModel : BaseModel +{ + public OrderPagingModel PagingContext { get; set; } = new(); + + /// + /// The list shows the whole company's requests and not only the customer's own + /// + public bool IsCompanyList { get; set; } + + public IList Approvals { get; set; } = new List(); + + public class OrderApprovalRowModel : BaseEntityModel + { + public DateTime CreatedOn { get; set; } + public int StatusId { get; set; } + public string Status { get; set; } + public bool IsPending { get; set; } + + /// + /// The buyer's name or e-mail; filled for the company list + /// + public string Buyer { get; set; } + + public int ItemCount { get; set; } + public string Total { get; set; } + } +} diff --git a/src/Web/Grand.Web.Models/Orders/OrderApprovalModel.cs b/src/Web/Grand.Web.Models/Orders/OrderApprovalModel.cs new file mode 100644 index 0000000000..d250603b04 --- /dev/null +++ b/src/Web/Grand.Web.Models/Orders/OrderApprovalModel.cs @@ -0,0 +1,48 @@ +using Grand.Infrastructure.Models; + +namespace Grand.Web.Models.Orders; + +public class OrderApprovalModel : BaseEntityModel +{ + public DateTime CreatedOn { get; set; } + public int StatusId { get; set; } + public string Status { get; set; } + public string Buyer { get; set; } + + public DateTime? DecidedOn { get; set; } + public string DecidedBy { get; set; } + public string DecisionComment { get; set; } + + /// + /// The viewer is an approver of the company and the request is still pending + /// + public bool CanDecide { get; set; } + + /// + /// The viewer is the buyer and the request is pending or approved but not yet used + /// + public bool CanWithdraw { get; set; } + + public IList Items { get; set; } = new List(); + public OrderApprovalSummaryModel Summary { get; set; } = new(); + + public class OrderApprovalItemModel : BaseModel + { + public string ProductName { get; set; } + public string Sku { get; set; } + public int Quantity { get; set; } + public string AttributeDescription { get; set; } + public string UnitPrice { get; set; } + public string LineTotal { get; set; } + } + + public class OrderApprovalSummaryModel : BaseModel + { + public string SubTotal { get; set; } + public string Discount { get; set; } + public string ShippingMethod { get; set; } + public string Shipping { get; set; } + public string Tax { get; set; } + public string Total { get; set; } + } +} diff --git a/src/Web/Grand.Web.Models/ShoppingCart/ShoppingCartModel.cs b/src/Web/Grand.Web.Models/ShoppingCart/ShoppingCartModel.cs index 991decd291..3c5f3f7bff 100644 --- a/src/Web/Grand.Web.Models/ShoppingCart/ShoppingCartModel.cs +++ b/src/Web/Grand.Web.Models/ShoppingCart/ShoppingCartModel.cs @@ -19,6 +19,11 @@ public class ShoppingCartModel : BaseModel public IList Warnings { get; set; } = new List(); public string MinOrderSubtotalWarning { get; set; } + + /// + /// Where the cart's order approval stands (waiting, approved); null when there is nothing to say + /// + public string OrderApprovalNote { get; set; } public bool ShowCheckoutAsGuestButton { get; set; } public bool IsGuest { get; set; } public bool TermsOfServiceOnShoppingCartPage { get; set; } diff --git a/src/Web/Grand.Web/Controllers/CheckoutController.cs b/src/Web/Grand.Web/Controllers/CheckoutController.cs index 2abede8661..ad8ebe6e90 100644 --- a/src/Web/Grand.Web/Controllers/CheckoutController.cs +++ b/src/Web/Grand.Web/Controllers/CheckoutController.cs @@ -575,7 +575,7 @@ await _customerService.UpdateUserField(_contextAccessor.WorkContext.Curr SystemCustomerFieldNames.SelectedPaymentMethod, null, _contextAccessor.StoreContext.CurrentStore.Id); var confirmOrderModel = await _mediator.Send(new GetConfirmOrder { - Cart = cart, Customer = _contextAccessor.WorkContext.CurrentCustomer, Language = _contextAccessor.WorkContext.WorkingLanguage, + Cart = cart, Customer = _contextAccessor.WorkContext.CurrentCustomer, Language = _contextAccessor.WorkContext.WorkingLanguage, Currency = _contextAccessor.WorkContext.WorkingCurrency, TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType, Store = _contextAccessor.StoreContext.CurrentStore }); return Json(new { @@ -642,7 +642,7 @@ await _customerService.UpdateUserField(_contextAccessor.WorkContext.CurrentCusto _contextAccessor.StoreContext.CurrentStore.Id); var confirmOrderModel = await _mediator.Send(new GetConfirmOrder { - Cart = cart, Customer = _contextAccessor.WorkContext.CurrentCustomer, Language = _contextAccessor.WorkContext.WorkingLanguage, + Cart = cart, Customer = _contextAccessor.WorkContext.CurrentCustomer, Language = _contextAccessor.WorkContext.WorkingLanguage, Currency = _contextAccessor.WorkContext.WorkingCurrency, TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType, Store = _contextAccessor.StoreContext.CurrentStore }); return Json(new { @@ -755,7 +755,7 @@ public virtual async Task ConfirmOrder() //error var confirmOrderModel = await _mediator.Send(new GetConfirmOrder { - Cart = cart, Customer = _contextAccessor.WorkContext.CurrentCustomer, Language = _contextAccessor.WorkContext.WorkingLanguage, + Cart = cart, Customer = _contextAccessor.WorkContext.CurrentCustomer, Language = _contextAccessor.WorkContext.WorkingLanguage, Currency = _contextAccessor.WorkContext.WorkingCurrency, TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType, Store = _contextAccessor.StoreContext.CurrentStore }); foreach (var error in placeOrderResult.Errors) @@ -985,7 +985,7 @@ await _customerService.UpdateUserField(_contextAccessor.WorkContext.Curr SystemCustomerFieldNames.SelectedPaymentMethod, null, _contextAccessor.StoreContext.CurrentStore.Id); var confirmOrderModel = await _mediator.Send(new GetConfirmOrder { - Cart = cart, Customer = _contextAccessor.WorkContext.CurrentCustomer, Language = _contextAccessor.WorkContext.WorkingLanguage, + Cart = cart, Customer = _contextAccessor.WorkContext.CurrentCustomer, Language = _contextAccessor.WorkContext.WorkingLanguage, Currency = _contextAccessor.WorkContext.WorkingCurrency, TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType, Store = _contextAccessor.StoreContext.CurrentStore }); return Json(new { @@ -1006,7 +1006,7 @@ private async Task LoadStepAfterPaymentMethod(IPaymentProvider payme && _paymentSettings.SkipPaymentInfo)) { var confirmOrderModel = await _mediator.Send(new GetConfirmOrder { - Cart = cart, Customer = _contextAccessor.WorkContext.CurrentCustomer, Language = _contextAccessor.WorkContext.WorkingLanguage, + Cart = cart, Customer = _contextAccessor.WorkContext.CurrentCustomer, Language = _contextAccessor.WorkContext.WorkingLanguage, Currency = _contextAccessor.WorkContext.WorkingCurrency, TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType, Store = _contextAccessor.StoreContext.CurrentStore }); return Json(new { diff --git a/src/Web/Grand.Web/Controllers/OrderApprovalController.cs b/src/Web/Grand.Web/Controllers/OrderApprovalController.cs new file mode 100644 index 0000000000..4904bdb358 --- /dev/null +++ b/src/Web/Grand.Web/Controllers/OrderApprovalController.cs @@ -0,0 +1,156 @@ +using Grand.Domain.Customers; +using Grand.Infrastructure; +using Grand.Mediator; +using Grand.SharedKernel.Attributes; +using Grand.Web.Commands.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.Extensions; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Grand.Web.Features.Models.Orders; +using Grand.Web.Models.Orders; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Controllers; + +/// +/// Order approvals of a company: a limited sub-account asks for an approval of its cart, an approver +/// decides it. Switched off together with the sub-accounts feature. +/// +[DenySystemAccount] +[ApiGroup(SharedKernel.Extensions.ApiConstants.ApiGroupNameV2)] +public class OrderApprovalController : BasePublicController +{ + private readonly IContextAccessor _contextAccessor; + private readonly CustomerSettings _customerSettings; + private readonly IMediator _mediator; + private readonly IRequestTranslationService _translationService; + + public OrderApprovalController(IContextAccessor contextAccessor, IMediator mediator, + IRequestTranslationService translationService, CustomerSettings customerSettings) + { + _contextAccessor = contextAccessor; + _mediator = mediator; + _translationService = translationService; + _customerSettings = customerSettings; + } + + /// + /// The sub-accounts switch also switches the approvals off, so every action starts with it + /// + private bool FeatureOff => _customerSettings.HideSubAccountsTab; + + //My account / Order approvals + [HttpGet] + [CustomerGroupAuthorize(SystemCustomerGroupNames.Registered)] + public virtual async Task List(OrderPagingModel command) + { + if (FeatureOff) + return RedirectToRoute("CustomerInfo"); + + var model = await _mediator.Send(new GetOrderApprovals { + Customer = _contextAccessor.WorkContext.CurrentCustomer, + Store = _contextAccessor.StoreContext.CurrentStore, + Language = _contextAccessor.WorkContext.WorkingLanguage, + Command = command + }); + return View(model); + } + + //My account / Order approval details; a missing, foreign or hidden request is answered like any other + [HttpGet] + [CustomerGroupAuthorize(SystemCustomerGroupNames.Registered)] + public virtual async Task Details(string id) + { + if (FeatureOff) + return RedirectToRoute("CustomerInfo"); + + var model = await _mediator.Send(new GetOrderApproval { + Customer = _contextAccessor.WorkContext.CurrentCustomer, + Store = _contextAccessor.StoreContext.CurrentStore, + Language = _contextAccessor.WorkContext.WorkingLanguage, + Id = id + }); + if (model == null) + return RedirectToRoute("CustomerOrderApprovals"); + + return View(model); + } + + //Checkout confirm step: ask the approvers to approve the cart + [HttpPost] + [CustomerGroupAuthorize(SystemCustomerGroupNames.Registered)] + public virtual async Task Submit() + { + if (FeatureOff) + return RedirectToRoute("CustomerInfo"); + + var (success, error) = await _mediator.Send(new SubmitOrderApprovalCommand { + Customer = _contextAccessor.WorkContext.CurrentCustomer, + Store = _contextAccessor.StoreContext.CurrentStore, + Language = _contextAccessor.WorkContext.WorkingLanguage, + // the calculation context is the request's own, nothing of it is posted + Context = _contextAccessor.CalculationContext(), + Host = _contextAccessor.StoreContext.CurrentHost + }); + + if (success) + Success(_translationService.GetResource("OrderApproval.Submit.Success")); + else + Error(string.IsNullOrEmpty(error) ? _translationService.GetResource("OrderApproval.Submit.Failed") : error); + + // checkout data is reset when checkout is opened again, so the buyer goes back to the cart + return RedirectToRoute("ShoppingCart"); + } + + [HttpPost] + [CustomerGroupAuthorize(SystemCustomerGroupNames.Registered)] + public virtual async Task Decide(string id, bool approve, string comment) + { + if (FeatureOff) + return RedirectToRoute("CustomerInfo"); + + var decided = await _mediator.Send(new DecideOrderApprovalCommand { + Approver = _contextAccessor.WorkContext.CurrentCustomer, + Store = _contextAccessor.StoreContext.CurrentStore, + ApprovalId = id, + Approve = approve, + Comment = comment, + Language = _contextAccessor.WorkContext.WorkingLanguage, + Host = _contextAccessor.StoreContext.CurrentHost + }); + + if (decided) + Success(_translationService.GetResource(approve + ? "OrderApproval.Decide.Approved" + : "OrderApproval.Decide.Rejected")); + else + Error(_translationService.GetResource("OrderApproval.Decide.Refused")); + + return RedirectToRoute("CustomerOrderApprovals"); + } + + /// Approval identifier + /// The request was withdrawn from the confirm step, so the buyer returns to the cart + [HttpPost] + [CustomerGroupAuthorize(SystemCustomerGroupNames.Registered)] + public virtual async Task Withdraw(string id, bool toCart) + { + if (FeatureOff) + return RedirectToRoute("CustomerInfo"); + + var withdrawn = await _mediator.Send(new WithdrawOrderApprovalCommand { + Customer = _contextAccessor.WorkContext.CurrentCustomer, + Store = _contextAccessor.StoreContext.CurrentStore, + ApprovalId = id + }); + + if (withdrawn) + Success(_translationService.GetResource("OrderApproval.Withdraw.Success")); + else + Error(_translationService.GetResource("OrderApproval.Withdraw.Refused")); + + return RedirectToRoute(toCart ? "ShoppingCart" : "CustomerOrderApprovals"); + } +} diff --git a/src/Web/Grand.Web/Endpoints/EndpointProvider.cs b/src/Web/Grand.Web/Endpoints/EndpointProvider.cs index 36524a3b05..fb3ff7f809 100644 --- a/src/Web/Grand.Web/Endpoints/EndpointProvider.cs +++ b/src/Web/Grand.Web/Endpoints/EndpointProvider.cs @@ -160,6 +160,26 @@ private void RegisterAccountRoute(IEndpointRouteBuilder endpointRouteBuilder, st pattern + "account/subaccountdelete", new { controller = "Account", action = "SubAccountDelete" }); + endpointRouteBuilder.MapControllerRoute("CustomerOrderApprovals", + pattern + "account/orderapprovals", + new { controller = "OrderApproval", action = "List" }); + + endpointRouteBuilder.MapControllerRoute("CustomerOrderApproval", + pattern + "account/orderapproval", + new { controller = "OrderApproval", action = "Details" }); + + endpointRouteBuilder.MapControllerRoute("OrderApprovalSubmit", + pattern + "orderapproval/submit", + new { controller = "OrderApproval", action = "Submit" }); + + endpointRouteBuilder.MapControllerRoute("OrderApprovalDecide", + pattern + "orderapproval/decide", + new { controller = "OrderApproval", action = "Decide" }); + + endpointRouteBuilder.MapControllerRoute("OrderApprovalWithdraw", + pattern + "orderapproval/withdraw", + new { controller = "OrderApproval", action = "Withdraw" }); + endpointRouteBuilder.MapControllerRoute("CustomerDownloadableProducts", pattern + "account/downloadableproducts", new { controller = "Account", action = "DownloadableProducts" }); diff --git a/src/Web/Grand.Web/Features/Handlers/Checkout/GetConfirmOrderHandler.cs b/src/Web/Grand.Web/Features/Handlers/Checkout/GetConfirmOrderHandler.cs index 4ae07890d5..6ec54ca966 100644 --- a/src/Web/Grand.Web/Features/Handlers/Checkout/GetConfirmOrderHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Checkout/GetConfirmOrderHandler.cs @@ -1,10 +1,13 @@ -using Grand.Business.Core.Interfaces.Checkout.Payments; +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.Queries.Checkout.Orders; using Grand.Domain.Common; using Grand.Domain.Customers; using Grand.Domain.Orders; using Grand.Domain.Shipping; +using Grand.Web.Common.Localization; +using Grand.Web.Extensions; using Grand.Web.Features.Models.Checkout; using Grand.Web.Features.Models.Common; using Grand.Web.Models.Checkout; @@ -16,17 +19,23 @@ namespace Grand.Web.Features.Handlers.Checkout; public class GetConfirmOrderHandler : IRequestHandler { private readonly ICountryService _countryService; + private readonly IDateTimeService _dateTimeService; private readonly IMediator _mediator; + private readonly IOrderApprovalService _orderApprovalService; private readonly OrderSettings _orderSettings; private readonly IPaymentService _paymentService; private readonly ShippingSettings _shippingSettings; + private readonly IRequestTranslationService _translationService; public GetConfirmOrderHandler( ICountryService countryService, IPaymentService paymentService, IMediator mediator, OrderSettings orderSettings, - ShippingSettings shippingSettings) + ShippingSettings shippingSettings, + IOrderApprovalService orderApprovalService, + IDateTimeService dateTimeService, + IRequestTranslationService translationService) { _countryService = countryService; @@ -34,6 +43,9 @@ public GetConfirmOrderHandler( _mediator = mediator; _orderSettings = orderSettings; _shippingSettings = shippingSettings; + _orderApprovalService = orderApprovalService; + _dateTimeService = dateTimeService; + _translationService = translationService; } public async Task Handle(GetConfirmOrder request, CancellationToken cancellationToken) @@ -43,9 +55,41 @@ public async Task Handle(GetConfirmOrder request, Cancella TermsOfServiceOnOrderConfirmPage = _orderSettings.TermsOfServiceOnOrderConfirmPage }; await PrepareOrderReviewData(model, request); + await PrepareOrderApproval(model, request); return model; } + /// + /// Tells the confirm step whether the order needs an approval first; left null when it does not + /// + private async Task PrepareOrderApproval(CheckoutConfirmModel model, GetConfirmOrder request) + { + var state = await _orderApprovalService.GetCheckoutState(request.Cart, request.CalculationContext()); + if (!state.Required) + return; + + var approval = state.Active; + var approvalModel = new CheckoutConfirmModel.OrderApprovalStateModel { + Required = true, + Approved = approval?.Status == OrderApprovalStatus.Approved, + Pending = approval?.Status == OrderApprovalStatus.Pending, + ApprovalId = approval?.Id + }; + + if (approvalModel.Pending) + { + approvalModel.Message = _translationService.GetResource("OrderApproval.Confirm.Pending"); + approvalModel.PendingSince = _dateTimeService + .ConvertToUserTime(approval.CreatedOnUtc, DateTimeKind.Utc).ToString("g"); + } + else if (!approvalModel.Approved) + { + approvalModel.Message = _translationService.GetResource("OrderApproval.Confirm.Required"); + } + + model.OrderApproval = approvalModel; + } + private async Task PrepareOrderReviewData(CheckoutConfirmModel model, GetConfirmOrder request) { //billing info diff --git a/src/Web/Grand.Web/Features/Handlers/Customers/GetNavigationHandler.cs b/src/Web/Grand.Web/Features/Handlers/Customers/GetNavigationHandler.cs index af5a2c2e9a..b580e92a64 100644 --- a/src/Web/Grand.Web/Features/Handlers/Customers/GetNavigationHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Customers/GetNavigationHandler.cs @@ -1,4 +1,5 @@ -using Grand.Domain.Customers; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Domain.Customers; using Grand.Domain.Orders; using Grand.Domain.Vendors; using Grand.Infrastructure.Configuration; @@ -11,6 +12,7 @@ namespace Grand.Web.Features.Handlers.Customers; public class GetNavigationHandler : IRequestHandler { private readonly CustomerSettings _customerSettings; + private readonly ICustomerService _customerService; private readonly LoyaltyPointsSettings _loyaltyPointsSettings; private readonly OidcLoginConfig _loginConfig; private readonly OrderSettings _orderSettings; @@ -21,13 +23,15 @@ public GetNavigationHandler( LoyaltyPointsSettings loyaltyPointsSettings, OrderSettings orderSettings, VendorSettings vendorSettings, - OidcLoginConfig loginConfig) + OidcLoginConfig loginConfig, + ICustomerService customerService) { _customerSettings = customerSettings; _loyaltyPointsSettings = loyaltyPointsSettings; _orderSettings = orderSettings; _vendorSettings = vendorSettings; _loginConfig = loginConfig; + _customerService = customerService; } public async Task Handle(GetNavigation request, CancellationToken cancellationToken) @@ -45,9 +49,22 @@ public async Task Handle(GetNavigation request, Cancell HideReviews = _customerSettings.HideReviewsTab, HideCourses = _customerSettings.HideCoursesTab, HideSubAccounts = _customerSettings.HideSubAccountsTab || !string.IsNullOrEmpty(request.Customer.OwnerId), + HideOrderApprovals = _customerSettings.HideSubAccountsTab || !await IsInCompany(request.Customer), SelectedTab = (AccountNavigationEnum)request.SelectedTabId }; - return await Task.FromResult(model); + return model; + } + + /// + /// A sub-account always belongs to a company; an owner does once it has a sub-account + /// + private async Task IsInCompany(Customer customer) + { + if (!string.IsNullOrEmpty(customer.OwnerId)) + return true; + + var subAccounts = await _customerService.GetAllCustomers(ownerId: customer.Id, pageSize: 1); + return subAccounts.TotalCount > 0; } } \ No newline at end of file diff --git a/src/Web/Grand.Web/Features/Handlers/Orders/GetOrderApprovalHandler.cs b/src/Web/Grand.Web/Features/Handlers/Orders/GetOrderApprovalHandler.cs new file mode 100644 index 0000000000..1ceda4e638 --- /dev/null +++ b/src/Web/Grand.Web/Features/Handlers/Orders/GetOrderApprovalHandler.cs @@ -0,0 +1,91 @@ +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Utilities.Checkout; +using Grand.Domain.Orders; +using Grand.Mediator; +using Grand.Web.Common.Localization; +using Grand.Web.Features.Models.Orders; +using Grand.Web.Models.Orders; + +namespace Grand.Web.Features.Handlers.Orders; + +public class GetOrderApprovalHandler : IRequestHandler +{ + private readonly ICustomerService _customerService; + private readonly IDateTimeService _dateTimeService; + private readonly IEnumTranslationService _enumTranslationService; + private readonly IOrderApprovalService _orderApprovalService; + + public GetOrderApprovalHandler(IOrderApprovalService orderApprovalService, ICustomerService customerService, + IDateTimeService dateTimeService, IEnumTranslationService enumTranslationService) + { + _orderApprovalService = orderApprovalService; + _customerService = customerService; + _dateTimeService = dateTimeService; + _enumTranslationService = enumTranslationService; + } + + public async Task Handle(GetOrderApproval request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request.Customer); + ArgumentNullException.ThrowIfNull(request.Store); + + if (string.IsNullOrEmpty(request.Id)) + return null; + + // a missing, foreign-company, other-store or somebody else's request all give the same answer: null + var approval = await _orderApprovalService.GetById(request.Id, OrderApprovalRules.CompanyId(request.Customer)); + if (approval == null || approval.StoreId != request.Store.Id) + return null; + + var isApprover = OrderApprovalRules.CanApprove(request.Customer); + var isBuyer = approval.CustomerId == request.Customer.Id; + if (!isApprover && !isBuyer) + return null; + + var model = new OrderApprovalModel { + Id = approval.Id, + CreatedOn = _dateTimeService.ConvertToUserTime(approval.CreatedOnUtc, DateTimeKind.Utc), + StatusId = (int)approval.Status, + Status = _enumTranslationService.GetTranslationEnum(approval.Status), + DecisionComment = approval.DecisionComment, + CanDecide = isApprover && approval.Status == OrderApprovalStatus.Pending, + CanWithdraw = isBuyer && approval.Status is OrderApprovalStatus.Pending or OrderApprovalStatus.Approved + }; + + if (approval.DecidedOnUtc.HasValue) + { + model.DecidedOn = _dateTimeService.ConvertToUserTime(approval.DecidedOnUtc.Value, DateTimeKind.Utc); + if (!string.IsNullOrEmpty(approval.DecidedByCustomerId)) + model.DecidedBy = OrderApprovalBuyerName.For( + await _customerService.GetCustomerById(approval.DecidedByCustomerId)); + } + + var buyer = isBuyer ? request.Customer : await _customerService.GetCustomerById(approval.CustomerId); + model.Buyer = OrderApprovalBuyerName.For(buyer); + + foreach (var item in approval.Items) + model.Items.Add(new OrderApprovalModel.OrderApprovalItemModel { + ProductName = item.ProductName, + Sku = item.Sku, + Quantity = item.Quantity, + AttributeDescription = item.AttributeDescription, + UnitPrice = item.UnitPrice, + LineTotal = item.LineTotal + }); + + var summary = approval.Summary; + if (summary != null) + model.Summary = new OrderApprovalModel.OrderApprovalSummaryModel { + SubTotal = summary.SubTotal, + Discount = summary.Discount, + ShippingMethod = summary.ShippingMethod, + Shipping = summary.Shipping, + Tax = summary.Tax, + Total = summary.Total + }; + + return model; + } +} diff --git a/src/Web/Grand.Web/Features/Handlers/Orders/GetOrderApprovalsHandler.cs b/src/Web/Grand.Web/Features/Handlers/Orders/GetOrderApprovalsHandler.cs new file mode 100644 index 0000000000..a23a4c5389 --- /dev/null +++ b/src/Web/Grand.Web/Features/Handlers/Orders/GetOrderApprovalsHandler.cs @@ -0,0 +1,73 @@ +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Utilities.Checkout; +using Grand.Domain.Customers; +using Grand.Domain.Orders; +using Grand.Mediator; +using Grand.Web.Common.Localization; +using Grand.Web.Features.Models.Orders; +using Grand.Web.Models.Orders; + +namespace Grand.Web.Features.Handlers.Orders; + +public class GetOrderApprovalsHandler : IRequestHandler +{ + private const int DefaultPageSize = 10; + private const int MaxPageSize = 50; + + private readonly ICustomerService _customerService; + private readonly IDateTimeService _dateTimeService; + private readonly IEnumTranslationService _enumTranslationService; + private readonly IOrderApprovalService _orderApprovalService; + + public GetOrderApprovalsHandler(IOrderApprovalService orderApprovalService, ICustomerService customerService, + IDateTimeService dateTimeService, IEnumTranslationService enumTranslationService) + { + _orderApprovalService = orderApprovalService; + _customerService = customerService; + _dateTimeService = dateTimeService; + _enumTranslationService = enumTranslationService; + } + + public async Task Handle(GetOrderApprovals request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request.Customer); + ArgumentNullException.ThrowIfNull(request.Store); + + var pageIndex = Math.Max(request.Command.PageNumber, 1) - 1; + var pageSize = request.Command.PageSize <= 0 ? DefaultPageSize : Math.Min(request.Command.PageSize, MaxPageSize); + + // approvers (the owner and approver sub-accounts) see the company's requests, buyers only their own + var isCompanyList = OrderApprovalRules.CanApprove(request.Customer); + var approvals = isCompanyList + ? await _orderApprovalService.GetForCompany(OrderApprovalRules.CompanyId(request.Customer), + request.Store.Id, pageIndex, pageSize) + : await _orderApprovalService.GetForCustomer(request.Customer.Id, request.Store.Id, pageIndex, pageSize); + + var model = new OrderApprovalListModel { IsCompanyList = isCompanyList }; + model.PagingContext.LoadPagedList(approvals); + + var buyers = new Dictionary(); + if (isCompanyList && approvals.Count > 0) + foreach (var buyer in await _customerService.GetCustomersByIds( + approvals.Select(x => x.CustomerId).Distinct().ToArray())) + buyers[buyer.Id] = buyer; + + foreach (var approval in approvals) + model.Approvals.Add(new OrderApprovalListModel.OrderApprovalRowModel { + Id = approval.Id, + CreatedOn = _dateTimeService.ConvertToUserTime(approval.CreatedOnUtc, DateTimeKind.Utc), + StatusId = (int)approval.Status, + Status = _enumTranslationService.GetTranslationEnum(approval.Status), + IsPending = approval.Status == OrderApprovalStatus.Pending, + Buyer = isCompanyList + ? OrderApprovalBuyerName.For(buyers.GetValueOrDefault(approval.CustomerId)) + : null, + ItemCount = approval.Items.Count, + Total = approval.Summary?.Total + }); + + return model; + } +} diff --git a/src/Web/Grand.Web/Features/Handlers/Orders/OrderApprovalBuyerName.cs b/src/Web/Grand.Web/Features/Handlers/Orders/OrderApprovalBuyerName.cs new file mode 100644 index 0000000000..c6f8e70309 --- /dev/null +++ b/src/Web/Grand.Web/Features/Handlers/Orders/OrderApprovalBuyerName.cs @@ -0,0 +1,21 @@ +using Grand.Domain.Common; +using Grand.Domain.Customers; + +namespace Grand.Web.Features.Handlers.Orders; + +internal static class OrderApprovalBuyerName +{ + /// + /// Gets the name an approver recognizes the buyer by: the full name and the e-mail, whichever exist + /// + public static string For(Customer buyer) + { + if (buyer == null) return null; + + var name = $"{buyer.GetUserFieldFromEntity(SystemCustomerFieldNames.FirstName)} " + + $"{buyer.GetUserFieldFromEntity(SystemCustomerFieldNames.LastName)}"; + name = name.Trim(); + if (string.IsNullOrEmpty(name)) return buyer.Email; + return string.IsNullOrEmpty(buyer.Email) ? name : $"{name} ({buyer.Email})"; + } +} diff --git a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetShoppingCartHandler.cs b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetShoppingCartHandler.cs index 51c7352174..286f25f961 100644 --- a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetShoppingCartHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetShoppingCartHandler.cs @@ -39,6 +39,7 @@ public class GetShoppingCartHandler : IRequestHandler Handle(GetShoppingCart request, CancellationToken cancellationToken) @@ -138,6 +144,8 @@ public async Task Handle(GetShoppingCart request, Cancellatio await PrepareSimpleProperties(model, request); + await PrepareOrderApprovalNote(model, request); + await PrepareCheckoutAttributes(model, request); await PrepareCartItems(model, request); @@ -213,6 +221,35 @@ await _currencyService.ConvertFromPrimaryStoreCurrency(_orderSettings.MinOrderSu #endregion } + /// + /// Tells a limited sub-account buyer where the request for the cart stands. Nobody else has approvals, so + /// they cost no query. + /// + private async Task PrepareOrderApprovalNote(ShoppingCartModel model, GetShoppingCart request) + { + var customer = request.Customer; + if (_customerSettings.HideSubAccountsTab || string.IsNullOrEmpty(customer.OwnerId) || + OrderApprovalRules.CanApprove(customer)) + return; + + var approval = await _orderApprovalService.GetActive(customer.Id, request.Store.Id); + if (approval == null) + return; + + if (approval.Status == OrderApprovalStatus.Pending) + { + var since = _dateTimeService.ConvertToUserTime(approval.CreatedOnUtc, DateTimeKind.Utc); + model.OrderApprovalNote = string.Format(_translationService.GetResource("OrderApproval.Cart.Pending"), + since.ToString("g")); + } + else if (approval.Status == OrderApprovalStatus.Approved && + approval.CartFingerprint == OrderApprovalFingerprint.Compute(request.Cart)) + { + // an approval holds for exactly the cart it was given for + model.OrderApprovalNote = _translationService.GetResource("OrderApproval.Cart.Approved"); + } + } + private async Task PrepareCheckoutAttributes(ShoppingCartModel model, GetShoppingCart request) { #region Checkout attributes diff --git a/src/Web/Grand.Web/Features/Models/Checkout/GetConfirmOrder.cs b/src/Web/Grand.Web/Features/Models/Checkout/GetConfirmOrder.cs index 3df8ec7662..d8306177e9 100644 --- a/src/Web/Grand.Web/Features/Models/Checkout/GetConfirmOrder.cs +++ b/src/Web/Grand.Web/Features/Models/Checkout/GetConfirmOrder.cs @@ -1,16 +1,21 @@ using Grand.Domain.Customers; +using Grand.Domain.Directory; using Grand.Domain.Localization; using Grand.Domain.Orders; using Grand.Domain.Stores; +using Grand.Domain.Tax; +using Grand.Web.Features.Models.ShoppingCart; using Grand.Web.Models.Checkout; using Grand.Mediator; namespace Grand.Web.Features.Models.Checkout; -public class GetConfirmOrder : IRequest +public class GetConfirmOrder : IRequest, ICartCalculationRequest { public Customer Customer { get; set; } public Language Language { get; set; } public Domain.Stores.Store Store { get; set; } + public Currency Currency { get; set; } + public TaxDisplayType TaxDisplayType { get; set; } public IList Cart { get; set; } } \ No newline at end of file diff --git a/src/Web/Grand.Web/Features/Models/Orders/GetOrderApproval.cs b/src/Web/Grand.Web/Features/Models/Orders/GetOrderApproval.cs new file mode 100644 index 0000000000..e181b62fba --- /dev/null +++ b/src/Web/Grand.Web/Features/Models/Orders/GetOrderApproval.cs @@ -0,0 +1,18 @@ +using Grand.Domain.Customers; +using Grand.Domain.Localization; +using Grand.Mediator; +using Grand.Web.Models.Orders; + +namespace Grand.Web.Features.Models.Orders; + +/// +/// Gets an order approval the customer may see; the result is null when it does not exist or the customer +/// may not see it +/// +public class GetOrderApproval : IRequest +{ + public Customer Customer { get; set; } + public Domain.Stores.Store Store { get; set; } + public Language Language { get; set; } + public string Id { get; set; } +} diff --git a/src/Web/Grand.Web/Features/Models/Orders/GetOrderApprovals.cs b/src/Web/Grand.Web/Features/Models/Orders/GetOrderApprovals.cs new file mode 100644 index 0000000000..5577e18a64 --- /dev/null +++ b/src/Web/Grand.Web/Features/Models/Orders/GetOrderApprovals.cs @@ -0,0 +1,14 @@ +using Grand.Domain.Customers; +using Grand.Domain.Localization; +using Grand.Mediator; +using Grand.Web.Models.Orders; + +namespace Grand.Web.Features.Models.Orders; + +public class GetOrderApprovals : IRequest +{ + public Customer Customer { get; set; } + public Domain.Stores.Store Store { get; set; } + public Language Language { get; set; } + public OrderPagingModel Command { get; set; } = new(); +} diff --git a/src/Web/Grand.Web/Views/Checkout/Partials/ConfirmOrder.cshtml b/src/Web/Grand.Web/Views/Checkout/Partials/ConfirmOrder.cshtml index dfa237a3a7..526c3be2eb 100644 --- a/src/Web/Grand.Web/Views/Checkout/Partials/ConfirmOrder.cshtml +++ b/src/Web/Grand.Web/Views/Checkout/Partials/ConfirmOrder.cshtml @@ -1,5 +1,10 @@ @*v-cloak: v-if does not suppress anything until Vue has mounted, so without it the raw {{ }} source of this block is on screen until the deferred bundle runs*@ +@{ + // a double click must not post the form twice: the second submit is ignored and the buttons are + // disabled once the form is on its way (the posted values are collected before the timeout runs) + const string submitOnce = "if (this.dataset.sent) { return false; } this.dataset.sent = '1'; var f = this; setTimeout(function () { f.querySelectorAll('button[type=submit]').forEach(function (b) { b.disabled = true; }); }, 0);"; +}
@await Component.InvokeAsync("Widget", new { widgetZone = "checkout_confirm_top" })
@@ -11,6 +16,15 @@ {{vmorder.MinOrderTotalWarning}}
+
+
{{vmorder.OrderApproval.Message}}
+
+ @Loc["OrderApproval.Confirm.PendingSince"] {{vmorder.OrderApproval.PendingSince}} +
+
+
+ @Loc["OrderApproval.Confirm.Approved"] +