diff --git a/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/CheckOrderStatusCommandHandler.cs b/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/CheckOrderStatusCommandHandler.cs index 1c9c3a6e1e..59d351ed62 100644 --- a/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/CheckOrderStatusCommandHandler.cs +++ b/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/CheckOrderStatusCommandHandler.cs @@ -27,6 +27,10 @@ public async Task Handle(CheckOrderStatusCommand request, CancellationToke { ArgumentNullException.ThrowIfNull(request.Order); + //an order waiting for an approval stays as it is until it is decided + if (request.Order.OrderStatusId == (int)OrderStatusSystem.AwaitingApproval) + return true; + if (request.Order.PaymentStatusId == PaymentStatus.Paid && !request.Order.PaidDateUtc.HasValue) { //ensure that paid date is set diff --git a/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/OrderNotificationCommandHandler.cs b/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/OrderNotificationCommandHandler.cs index 115ecd51e9..f0ef78cbd4 100644 --- a/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/OrderNotificationCommandHandler.cs +++ b/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/OrderNotificationCommandHandler.cs @@ -43,7 +43,7 @@ public async Task Handle(OrderNotificationCommand request, CancellationToken can try { - if (request.OriginalCustomerIfImpersonated != null) + if (request.OriginalCustomerIfImpersonated != null && request.AddOrderPlacedNote) //this order is placed by a store administrator impersonating a customer await _orderService.InsertOrderNote(new OrderNote { Note = @@ -51,7 +51,7 @@ await _orderService.InsertOrderNote(new OrderNote { DisplayToCustomer = false, OrderId = request.Order.Id }); - else + else if (request.AddOrderPlacedNote) await _orderService.InsertOrderNote(new OrderNote { Note = "Order placed", DisplayToCustomer = false, @@ -59,39 +59,43 @@ await _orderService.InsertOrderNote(new OrderNote { }); //send email notifications - await _orderMessageProviderService.SendOrderPlacedStoreOwnerMessage(request.Order, request.Customer, - _languageSettings.DefaultAdminLanguageId, request.Host); + if (request.NotifyStoreOwnerAndVendors) + await _orderMessageProviderService.SendOrderPlacedStoreOwnerMessage(request.Order, request.Customer, + _languageSettings.DefaultAdminLanguageId, request.Host); - string orderPlacedAttachmentFilePath = string.Empty, orderPlacedAttachmentFileName = string.Empty; - var orderPlacedAttachments = new List(); - - try - { - orderPlacedAttachmentFilePath = - _orderSettings.AttachPdfInvoiceToOrderPlacedEmail && !_orderSettings.AttachPdfInvoiceToBinary - ? await _pdfService.PrintOrderToPdf(request.Order, request.Order.CustomerLanguageId) - : null; - orderPlacedAttachmentFileName = - _orderSettings.AttachPdfInvoiceToOrderPlacedEmail && !_orderSettings.AttachPdfInvoiceToBinary - ? "order.pdf" - : null; - orderPlacedAttachments = _orderSettings.AttachPdfInvoiceToOrderPlacedEmail && - _orderSettings.AttachPdfInvoiceToBinary - ? [ - await _pdfService.SaveOrderToBinary(request.Order, request.Order.CustomerLanguageId) - ] - : []; - } - catch (Exception ex) + if (request.NotifyCustomer) { - _logger.LogError(ex, "Error - order placed attachment file {OrderOrderNumber}", request.Order.OrderNumber); - } + string orderPlacedAttachmentFilePath = string.Empty, orderPlacedAttachmentFileName = string.Empty; + var orderPlacedAttachments = new List(); + + try + { + orderPlacedAttachmentFilePath = + _orderSettings.AttachPdfInvoiceToOrderPlacedEmail && !_orderSettings.AttachPdfInvoiceToBinary + ? await _pdfService.PrintOrderToPdf(request.Order, request.Order.CustomerLanguageId) + : null; + orderPlacedAttachmentFileName = + _orderSettings.AttachPdfInvoiceToOrderPlacedEmail && !_orderSettings.AttachPdfInvoiceToBinary + ? "order.pdf" + : null; + orderPlacedAttachments = _orderSettings.AttachPdfInvoiceToOrderPlacedEmail && + _orderSettings.AttachPdfInvoiceToBinary + ? [ + await _pdfService.SaveOrderToBinary(request.Order, request.Order.CustomerLanguageId) + ] + : []; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error - order placed attachment file {OrderOrderNumber}", request.Order.OrderNumber); + } - await _orderMessageProviderService.SendOrderPlacedCustomerMessage(request.Order, request.Customer, - request.Order.CustomerLanguageId, orderPlacedAttachmentFilePath, orderPlacedAttachmentFileName, - orderPlacedAttachments, request.Host); + await _orderMessageProviderService.SendOrderPlacedCustomerMessage(request.Order, request.Customer, + request.Order.CustomerLanguageId, orderPlacedAttachmentFilePath, orderPlacedAttachmentFileName, + orderPlacedAttachments, request.Host); + } - if (request.Order.OrderItems.Any(x => !string.IsNullOrEmpty(x.VendorId))) + if (request.NotifyStoreOwnerAndVendors && request.Order.OrderItems.Any(x => !string.IsNullOrEmpty(x.VendorId))) { var vendors = await _mediator.Send(new GetVendorsInOrderQuery { Order = request.Order }); foreach (var vendor in vendors) diff --git a/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/PlaceOrderCommandHandler.cs b/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/PlaceOrderCommandHandler.cs index bf73da8d34..1d19609c18 100644 --- a/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/PlaceOrderCommandHandler.cs +++ b/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/PlaceOrderCommandHandler.cs @@ -47,6 +47,8 @@ public class PlaceOrderCommandHandler : IRequestHandler _logger; private readonly IMediator _mediator; + private readonly IOrderApprovalService _orderApprovalService; + private readonly ITranslationService _translationService; private readonly ICatalogMessageProviderService _catalogMessageProviderService; private readonly IOrderService _orderService; private readonly OrderSettings _orderSettings; @@ -100,6 +102,8 @@ public PlaceOrderCommandHandler( IAuctionService auctionService, ICountryService countryService, IShoppingCartValidator shoppingCartValidator, + IOrderApprovalService orderApprovalService, + ITranslationService translationService, ShippingSettings shippingSettings, ShoppingCartSettings shoppingCartSettings, PaymentSettings paymentSettings, @@ -136,6 +140,8 @@ public PlaceOrderCommandHandler( _auctionService = auctionService; _countryService = countryService; _shoppingCartValidator = shoppingCartValidator; + _orderApprovalService = orderApprovalService; + _translationService = translationService; _shippingSettings = shippingSettings; _shoppingCartSettings = shoppingCartSettings; _paymentSettings = paymentSettings; @@ -163,6 +169,16 @@ public async Task Handle(PlaceOrderCommand command, Cancellati if (result.Errors.Any()) return result; + //an order over the customer's limit is placed awaiting an approval, with an allowed payment method only + var approval = await _orderApprovalService.RequiresApproval(details.Cart, details.Context); + details.ApprovalRequired = approval.Required; + if (details.ApprovalRequired && !_orderApprovalService.IsPaymentMethodAllowed(details.PaymentMethodSystemName)) + { + result.AddError(_translationService.GetResource("Checkout.OrderApproval.PaymentMethodNotAllowed", + command.Language.Id)); + return result; + } + #region Payment workflow var processPayment = await PrepareProcessPayment(details); @@ -194,7 +210,8 @@ await _mediator.Send(new OrderNotificationCommand { Order = result.PlacedOrder, Customer = details.Customer, OriginalCustomerIfImpersonated = command.OriginalCustomerIfImpersonated, - Host = command.Host + Host = command.Host, + NotifyStoreOwnerAndVendors = !details.ApprovalRequired }, cancellationToken); //check order status @@ -203,7 +220,8 @@ await _mediator.Send(new OrderNotificationCommand { //raise event await _mediator.Publish(new OrderPlacedEvent(result.PlacedOrder), cancellationToken); - if (result.PlacedOrder.PaymentStatusId == PaymentStatus.Paid) + //an order awaiting approval is processed as paid once it is approved (store owner and vendors hear of it then) + if (!details.ApprovalRequired && result.PlacedOrder.PaymentStatusId == PaymentStatus.Paid) await _mediator.Send(new ProcessOrderPaidCommand { Order = result.PlacedOrder }, cancellationToken); #endregion @@ -929,7 +947,9 @@ protected virtual Order PrepareOrderHeader(PaymentTransaction paymentTransaction CurrencyRate = details.CurrencyRate, Rate = details.Currency.Rate, AffiliateId = details.AffiliateId, - OrderStatusId = (int)OrderStatusSystem.Pending, + OrderStatusId = details.ApprovalRequired + ? (int)OrderStatusSystem.AwaitingApproval + : (int)OrderStatusSystem.Pending, PaymentMethodSystemName = paymentTransaction.PaymentMethodSystemName, PaymentOptionAttribute = details.Customer.GetUserFieldFromEntity(SystemCustomerFieldNames.PaymentOptionAttribute, diff --git a/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/ProcessOrderPaidCommandHandler.cs b/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/ProcessOrderPaidCommandHandler.cs index 32a83cdc4a..0f0b2c97a3 100644 --- a/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/ProcessOrderPaidCommandHandler.cs +++ b/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/ProcessOrderPaidCommandHandler.cs @@ -49,6 +49,10 @@ protected virtual async Task ProcessOrderPaid(Order order) { ArgumentNullException.ThrowIfNull(order); + //an order waiting for an approval is processed as paid once it is approved, not before + if (order.OrderStatusId == (int)OrderStatusSystem.AwaitingApproval) + return; + //raise event await _mediator.Publish(new OrderPaidEvent(order)); diff --git a/src/Business/Grand.Business.Checkout/Events/Orders/OrderCancelledOrderApprovalHandler.cs b/src/Business/Grand.Business.Checkout/Events/Orders/OrderCancelledOrderApprovalHandler.cs new file mode 100644 index 0000000000..f3624f1598 --- /dev/null +++ b/src/Business/Grand.Business.Checkout/Events/Orders/OrderCancelledOrderApprovalHandler.cs @@ -0,0 +1,46 @@ +using Grand.Business.Core.Events.Checkout.Orders; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Domain.Orders; +using Grand.Mediator; +using Microsoft.Extensions.Logging; + +namespace Grand.Business.Checkout.Events.Orders; + +/// +/// Closes the approval request of an order cancelled before anyone decided it +/// +public class OrderCancelledOrderApprovalHandler : INotificationHandler +{ + private readonly ILogger _logger; + private readonly IOrderApprovalService _orderApprovalService; + + public OrderCancelledOrderApprovalHandler(IOrderApprovalService orderApprovalService, + ILogger logger) + { + _orderApprovalService = orderApprovalService; + _logger = logger; + } + + public async Task Handle(OrderCancelledEvent notification, CancellationToken cancellationToken) + { + var order = notification.Order; + try + { + // a decision or a withdrawal cancels the order first and then saves its own status over this one + var approval = await _orderApprovalService.GetByOrderId(order.Id); + if (approval is not { Status: OrderApprovalStatus.Pending }) + return; + + approval.Status = OrderApprovalStatus.Cancelled; + approval.DecidedOnUtc = DateTime.UtcNow; + await _orderApprovalService.Update(approval); + } + catch (Exception ex) + { + // the order is already cancelled: publishing must complete whatever happens here + _logger.LogError(ex, + "Order {OrderNumber} ({OrderId}) was cancelled but its approval request could not be closed", + order.OrderNumber, order.Id); + } + } +} 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..26c3281878 --- /dev/null +++ b/src/Business/Grand.Business.Checkout/Events/Orders/OrderPlacedOrderApprovalHandler.cs @@ -0,0 +1,205 @@ +using Grand.Business.Core.Events.Checkout.Orders; +using Grand.Business.Core.Extensions; +using Grand.Business.Core.Interfaces.Catalog.Prices; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.Messages; +using Grand.Business.Core.Utilities.Checkout; +using Grand.Domain.Customers; +using Grand.Domain.Directory; +using Grand.Domain.Orders; +using Grand.Domain.Tax; +using Grand.Mediator; +using Microsoft.Extensions.Logging; + +namespace Grand.Business.Checkout.Events.Orders; + +/// +/// Records the approval request of an order placed awaiting approval and asks the company's approvers to decide it +/// +public class OrderPlacedOrderApprovalHandler : INotificationHandler +{ + private readonly ICurrencyService _currencyService; + private readonly ICustomerService _customerService; + private readonly ILogger _logger; + private readonly ICustomerMessageProviderService _messageProviderService; + private readonly IOrderApprovalService _orderApprovalService; + private readonly IPriceFormatter _priceFormatter; + private readonly IProductService _productService; + private readonly IStoreService _storeService; + + public OrderPlacedOrderApprovalHandler( + IOrderApprovalService orderApprovalService, + ICustomerService customerService, + ICustomerMessageProviderService messageProviderService, + IStoreService storeService, + IProductService productService, + IPriceFormatter priceFormatter, + ICurrencyService currencyService, + ILogger logger) + { + _orderApprovalService = orderApprovalService; + _customerService = customerService; + _messageProviderService = messageProviderService; + _storeService = storeService; + _productService = productService; + _priceFormatter = priceFormatter; + _currencyService = currencyService; + _logger = logger; + } + + public async Task Handle(OrderPlacedEvent notification, CancellationToken cancellationToken) + { + var order = notification.Order; + if (order.OrderStatusId != (int)OrderStatusSystem.AwaitingApproval) + return; + + try + { + await CreateApprovalRequest(order); + } + catch (Exception ex) + { + // the order is already placed: publishing must complete whatever happens here + _logger.LogError(ex, + "Order {OrderNumber} ({OrderId}) awaits approval but its approval request could not be created", + order.OrderNumber, order.Id); + } + } + + private async Task CreateApprovalRequest(Order order) + { + // one request per order, even if the event is raised twice + if (await _orderApprovalService.GetByOrderId(order.Id) != null) + return; + + var buyer = await _customerService.GetCustomerById(order.CustomerId); + if (buyer == null) + { + _logger.LogError( + "Order {OrderNumber} ({OrderId}) awaits approval but its approval request could not be created: buyer {CustomerId} not found", + order.OrderNumber, order.Id, order.CustomerId); + return; + } + + var companyOwnerId = string.IsNullOrEmpty(order.OwnerId) ? OrderApprovalRules.CompanyId(buyer) : order.OwnerId; + var currency = await _currencyService.GetCurrencyByCode(order.CustomerCurrencyCode); + + var approval = new OrderApproval { + StoreId = order.StoreId, + CompanyOwnerId = companyOwnerId, + CustomerId = order.CustomerId, + Status = OrderApprovalStatus.Pending, + OrderId = order.Id, + Amount = order.OrderTotal, + CurrencyCode = order.CustomerCurrencyCode, + Summary = PrepareSummary(order, currency) + }; + foreach (var item in await PrepareItems(order, currency)) + approval.Items.Add(item); + + await _orderApprovalService.Insert(approval); + + var store = await _storeService.GetStoreById(order.StoreId); + foreach (var approver in await GetApprovers(buyer, companyOwnerId, order.StoreId)) + try + { + await _messageProviderService.SendOrderApprovalRequestedMessage(approval, buyer, approver, store, + order.CustomerLanguageId); + } + catch (Exception ex) + { + // a failed e-mail must not stop the others, nor the order + _logger.LogError(ex, "Order approval request e-mail failed. Order {OrderId}, approver {ApproverId}", + order.Id, approver.Id); + } + } + + private async Task> PrepareItems(Order order, Currency currency) + { + var includingTax = order.CustomerTaxDisplayTypeId == TaxDisplayType.IncludingTax; + var items = new List(); + + foreach (var orderItem in order.OrderItems) + { + var product = await _productService.GetProductById(orderItem.ProductId); + items.Add(new OrderApprovalItem { + ProductId = orderItem.ProductId, + ProductName = product?.GetTranslation(x => x.Name, order.CustomerLanguageId), + Sku = orderItem.Sku, + Quantity = orderItem.Quantity, + AttributeDescription = ToPlainText(orderItem.AttributeDescription), + UnitPrice = _priceFormatter.FormatPrice(includingTax ? orderItem.UnitPriceInclTax : orderItem.UnitPriceExclTax, + currency), + LineTotal = _priceFormatter.FormatPrice(includingTax ? orderItem.PriceInclTax : orderItem.PriceExclTax, + currency) + }); + } + + return items; + } + + private OrderApprovalSummary PrepareSummary(Order order, Currency currency) + { + var includingTax = order.CustomerTaxDisplayTypeId == TaxDisplayType.IncludingTax; + var discount = includingTax + ? order.OrderSubTotalDiscountInclTax + order.OrderDiscount + : order.OrderSubTotalDiscountExclTax + order.OrderDiscount; + + return new OrderApprovalSummary { + SubTotal = _priceFormatter.FormatPrice(includingTax ? order.OrderSubtotalInclTax : order.OrderSubtotalExclTax, + currency), + Discount = discount != 0 ? _priceFormatter.FormatPrice(discount, currency) : null, + ShippingMethod = order.ShippingMethod, + Shipping = _priceFormatter.FormatPrice(includingTax ? order.OrderShippingInclTax : order.OrderShippingExclTax, + currency), + Tax = _priceFormatter.FormatPrice(order.OrderTax, currency), + Total = _priceFormatter.FormatPrice(order.OrderTotal, currency) + }; + } + + /// + /// The attribute description carries markup; the approval stores plain text, the views and e-mails encode it + /// + private static readonly System.Text.RegularExpressions.Regex LineBreak = new("", + System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled); + + private static readonly System.Text.RegularExpressions.Regex Tag = new("<[^>]*>", + System.Text.RegularExpressions.RegexOptions.Compiled); + + private static string ToPlainText(string html) + { + if (string.IsNullOrWhiteSpace(html)) + return html; + + var lines = LineBreak.Split(html) + .Select(x => Tag.Replace(x, string.Empty)) + .Select(x => System.Net.WebUtility.HtmlDecode(x).Trim()) + .Where(x => x.Length > 0); + return string.Join(", ", lines); + } + + /// + /// The company owner and every active approver sub-account of the store that has an e-mail address; + /// never the buyer + /// + private async Task> GetApprovers(Customer buyer, string companyOwnerId, string storeId) + { + var approvers = new List(); + + var owner = await _customerService.GetCustomerById(companyOwnerId); + if (owner is { Active: true, Deleted: false }) + approvers.Add(owner); + + var subAccounts = await _customerService.GetAllCustomers(ownerId: companyOwnerId, storeId: storeId); + approvers.AddRange(subAccounts.Where(x => x.SubAccountRole == SubAccountRole.Approver && x is { Active: true, Deleted: false })); + + return approvers + .Where(x => x.Id != buyer.Id && !string.IsNullOrWhiteSpace(x.Email)) + .DistinctBy(x => x.Id) + .ToList(); + } +} diff --git a/src/Business/Grand.Business.Checkout/Queries/Handlers/Orders/CanCancelOrderQueryHandler.cs b/src/Business/Grand.Business.Checkout/Queries/Handlers/Orders/CanCancelOrderQueryHandler.cs index e35ffea436..4e70ab2dc6 100644 --- a/src/Business/Grand.Business.Checkout/Queries/Handlers/Orders/CanCancelOrderQueryHandler.cs +++ b/src/Business/Grand.Business.Checkout/Queries/Handlers/Orders/CanCancelOrderQueryHandler.cs @@ -11,6 +11,8 @@ public Task Handle(CanCancelOrderQuery request, CancellationToken cancella var order = request.Order; ArgumentNullException.ThrowIfNull(request.Order); - return Task.FromResult(order.OrderStatusId == (int)OrderStatusSystem.Pending); + //an order awaiting approval can be cancelled as well; that also closes its approval request + return Task.FromResult(order.OrderStatusId is (int)OrderStatusSystem.Pending + or (int)OrderStatusSystem.AwaitingApproval); } } \ No newline at end of file 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..f1fe7cdeba --- /dev/null +++ b/src/Business/Grand.Business.Checkout/Services/Orders/OrderApprovalService.cs @@ -0,0 +1,135 @@ +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, + OrderApprovalSettings approvalSettings, + IMediator mediator) + { + _repository = repository; + _orderCalculationService = orderCalculationService; + _customerSettings = customerSettings; + _approvalSettings = approvalSettings; + _mediator = mediator; + } + + #endregion + + #region Fields + + private readonly IRepository _repository; + private readonly IOrderCalculationService _orderCalculationService; + private readonly CustomerSettings _customerSettings; + private readonly OrderApprovalSettings _approvalSettings; + 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 GetByOrderId(string orderId) + { + if (string.IsNullOrEmpty(orderId)) + return null; + + return await _repository.FirstOrDefaultAsync(_repository.Table.Where(x => x.OrderId == orderId)); + } + + public virtual async Task> GetForCompany(string companyOwnerId, string storeId, + int pageIndex, int pageSize) + { + // an empty id would match nothing meaningful, as in GetById + if (string.IsNullOrEmpty(companyOwnerId) || string.IsNullOrEmpty(storeId)) + return new PagedList([], pageIndex, 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) + { + if (string.IsNullOrEmpty(customerId) || string.IsNullOrEmpty(storeId)) + return new PagedList([], pageIndex, 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 RequiresApproval(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); + + var total = (await _orderCalculationService.GetShoppingCartTotal(cart, context))?.Total; + + // the total is only known once shipping is chosen; until then the rule is not evaluated + return new OrderApprovalCheckoutState(total.HasValue && total.Value > limit.Value, limit, total); + } + + public virtual bool IsPaymentMethodAllowed(string paymentMethodSystemName) + { + return !string.IsNullOrEmpty(paymentMethodSystemName) && + _approvalSettings.PaymentMethodSystemNames?.Contains(paymentMethodSystemName, + StringComparer.OrdinalIgnoreCase) == true; + } + + #endregion +} diff --git a/src/Business/Grand.Business.Checkout/Services/Orders/OrderService.cs b/src/Business/Grand.Business.Checkout/Services/Orders/OrderService.cs index eca4c3bc73..9e74ca04f3 100644 --- a/src/Business/Grand.Business.Checkout/Services/Orders/OrderService.cs +++ b/src/Business/Grand.Business.Checkout/Services/Orders/OrderService.cs @@ -24,14 +24,17 @@ public class OrderService : IOrderService /// /// Order repository /// Order note repository + /// Order approval repository /// Mediator public OrderService(IRepository orderRepository, IRepository orderNoteRepository, + IRepository orderApprovalRepository, IMediator mediator ) { _orderRepository = orderRepository; _orderNoteRepository = orderNoteRepository; + _orderApprovalRepository = orderApprovalRepository; _mediator = mediator; } @@ -46,6 +49,7 @@ IMediator mediator private readonly IRepository _orderRepository; private readonly IRepository _orderNoteRepository; + private readonly IRepository _orderApprovalRepository; private readonly IMediator _mediator; #endregion @@ -254,7 +258,10 @@ public virtual async Task UpdateOrder(Order order) /// /// Cancel UnPaid Orders and has pending status /// - /// Date at which all unPaid orders and has pending status Would be Canceled + /// + /// Date at which all unPaid orders and has pending status Would be Canceled; for an approved order the + /// approval date counts instead of the creation date + /// /// Checked between orders - an order is always cancelled completely public async Task CancelExpiredOrders(DateTime expirationDateUtc, CancellationToken cancellationToken = default) { @@ -265,7 +272,18 @@ public async Task CancelExpiredOrders(DateTime expirationDateUtc, CancellationTo || o.ShippingStatusId == ShippingStatus.ShippingNotRequired)) .ToList(); - foreach (var order in orders) + //an approved order was held in status Awaiting approval until the decision, + //so its payment window starts at the approval, not at the placement + var orderIds = orders.Select(o => o.Id).ToList(); + var approvedRecently = orderIds.Count > 0 + ? _orderApprovalRepository.Table + .Where(a => orderIds.Contains(a.OrderId) && a.Status == OrderApprovalStatus.Approved && + a.DecidedOnUtc >= expirationDateUtc) + .Select(a => a.OrderId) + .ToList() + : []; + + foreach (var order in orders.Where(o => !approvedRecently.Contains(o.Id))) { cancellationToken.ThrowIfCancellationRequested(); await _mediator.Send(new CancelOrderCommand { Order = order, NotifyCustomer = true }); 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/Commands/Messages/Common/OrderNotificationCommand.cs b/src/Business/Grand.Business.Core/Commands/Messages/Common/OrderNotificationCommand.cs index 85bdf36387..c6a0a50662 100644 --- a/src/Business/Grand.Business.Core/Commands/Messages/Common/OrderNotificationCommand.cs +++ b/src/Business/Grand.Business.Core/Commands/Messages/Common/OrderNotificationCommand.cs @@ -23,4 +23,23 @@ public class OrderNotificationCommand : IRequest /// Host of the storefront request that placed the order; the messages link to it /// public DomainHost Host { get; set; } + + /// + /// Gets or sets a value indicating whether the store owner and the vendors are told about the order; + /// false for an order that waits for an approval, they hear about it once it is approved. + /// The customer's message is sent unless is off + /// + public bool NotifyStoreOwnerAndVendors { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the customer gets the "order placed" message; off when the customer + /// already got it at placement and only the store owner and the vendors are told later (approved order) + /// + public bool NotifyCustomer { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the "Order placed" order note is added; off when the order already + /// got it at placement (approved order) + /// + public bool AddOrderPlacedNote { get; set; } = true; } 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..a5e22d02ce --- /dev/null +++ b/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IOrderApprovalService.cs @@ -0,0 +1,73 @@ +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 order waits for an approver's decision +/// +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 approval request of an order + /// + /// Order identifier + /// Approval; null when the order has none + Task GetByOrderId(string orderId); + + /// + /// 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 order being placed from the cart has to wait for an approval. + /// It does 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. + /// + /// Shopping cart of the customer + /// Customer, store and currency the cart is calculated for + Task RequiresApproval(IList cart, CalculationContext context); + + /// + /// Gets a value indicating whether the payment method may be used for an order that needs an approval + /// + /// Payment method system name + bool IsPaymentMethodAllowed(string paymentMethodSystemName); +} 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.Core/Utilities/Checkout/OrderApprovalCheckoutState.cs b/src/Business/Grand.Business.Core/Utilities/Checkout/OrderApprovalCheckoutState.cs new file mode 100644 index 0000000000..dee69ac42c --- /dev/null +++ b/src/Business/Grand.Business.Core/Utilities/Checkout/OrderApprovalCheckoutState.cs @@ -0,0 +1,9 @@ +namespace Grand.Business.Core.Utilities.Checkout; + +/// +/// Whether the order being placed needs an approval before it is released +/// +/// 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 +public record OrderApprovalCheckoutState(bool Required, double? Limit, double? Total); 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..0a2aefe2fa --- /dev/null +++ b/src/Business/Grand.Business.Core/Utilities/Checkout/OrderApprovalRules.cs @@ -0,0 +1,43 @@ +using Grand.Domain.Customers; +using Grand.Domain.Orders; + +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; + } + + /// + /// Gets a value indicating whether the customer may open the order's details page: its buyer or the + /// company owner (the same rule the order details page applies); an approver sub-account sees the + /// order through the approval only + /// + public static bool CanOpenOrder(Customer customer, Order order) + { + ArgumentNullException.ThrowIfNull(customer); + ArgumentNullException.ThrowIfNull(order); + return customer.Id == order.CustomerId || + (string.IsNullOrEmpty(customer.OwnerId) && customer.Id == order.OwnerId); + } +} diff --git a/src/Business/Grand.Business.Core/Utilities/Checkout/PlaceOrderContainer.cs b/src/Business/Grand.Business.Core/Utilities/Checkout/PlaceOrderContainer.cs index 11d7624ce8..5e83302d1b 100644 --- a/src/Business/Grand.Business.Core/Utilities/Checkout/PlaceOrderContainer.cs +++ b/src/Business/Grand.Business.Core/Utilities/Checkout/PlaceOrderContainer.cs @@ -38,6 +38,11 @@ public class PlaceOrderContainer public double CurrencyRate { get; set; } public string PrimaryCurrencyCode { get; set; } public string PaymentMethodSystemName { get; set; } + + /// + /// The order total exceeds the customer's order limit, so the order is placed awaiting an approval + /// + public bool ApprovalRequired { get; set; } public Address BillingAddress { get; set; } public Address ShippingAddress { get; set; } public ShippingStatus ShippingStatus { get; set; } 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..667526a7f0 --- /dev/null +++ b/src/Business/Grand.Business.Messages/Commands/Handlers/Messages/GetOrderApprovalTokensCommandHandler.cs @@ -0,0 +1,99 @@ +using Grand.Business.Core.Extensions; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Common.Localization; +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 +{ + private readonly IOrderService _orderService; + private readonly ITranslationService _translationService; + + public GetOrderApprovalTokensCommandHandler(ITranslationService translationService, IOrderService orderService) + { + _translationService = translationService; + _orderService = orderService; + } + + public async Task Handle(GetOrderApprovalTokensCommand request, CancellationToken cancellationToken) + { + var approval = request.Approval; + var url = (request.Host?.Url ?? request.Store.Url).Trim('/'); + //the order can be gone (deleted by an admin); the link then simply leads to a missing order + var order = string.IsNullOrEmpty(approval.OrderId) ? null : await _orderService.GetOrderById(approval.OrderId); + + var liquidOrderApproval = new LiquidOrderApproval { + BuyerName = request.Buyer.GetFullName(), + BuyerEmail = request.Buyer.Email, + //resource text, used in the subject as well: encoding would turn "Approuvé" into "Approuvé" + Status = approval.Status.GetTranslationEnum(_translationService, request.Language.Id), + Total = approval.Summary?.Total, + ItemsHtml = ItemsHtml(approval.Items, request.Language.Id), + DecisionComment = Encode(approval.DecisionComment), + Url = $"{url}/account/orderapproval?id={WebUtility.UrlEncode(approval.Id)}", + OrderNumber = order?.OrderNumber.ToString() ?? string.Empty, + // without an order the link leads to the request itself + OrderUrl = order == null + ? $"{url}/account/orderapproval?id={WebUtility.UrlEncode(approval.Id)}" + : $"{url}/orderdetails/{WebUtility.UrlEncode(approval.OrderId)}" + }; + return liquidOrderApproval; + } + + private static string Encode(string value) + { + return WebUtility.HtmlEncode(value ?? string.Empty); + } + + private string Label(string key, string defaultValue, string languageId) + { + return Encode(_translationService.GetResource(key, languageId, defaultValue)); + } + + /// + /// Every value in the table was typed or formatted outside of the template, so it is encoded here - + /// the labels too, they come from a translator + /// + private string ItemsHtml(IEnumerable items, string languageId) + { + 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("
") + .Append(Label("OrderApproval.Email.Product", "Product", languageId)).Append("") + .Append(Label("OrderApproval.Email.Price", "Price", languageId)).Append("") + .Append(Label("OrderApproval.Email.Quantity", "Quantity", languageId)).Append("") + .Append(Label("OrderApproval.Email.Total", "Total", languageId)).Append("
").Append(Encode(item.ProductName)); + if (!string.IsNullOrEmpty(item.AttributeDescription)) + sb.Append("
").Append(Encode(item.AttributeDescription)); + if (!string.IsNullOrEmpty(item.Sku)) + sb.Append("
").Append(Label("OrderApproval.Email.Sku", "SKU", languageId)).Append(": ") + .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..308d1e626e --- /dev/null +++ b/src/Business/Grand.Business.Messages/DotLiquidDrops/LiquidOrderApproval.cs @@ -0,0 +1,52 @@ +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; } + + /// + /// Number of the order awaiting the decision; empty when the order no longer exists + /// + public string OrderNumber { get; set; } + + /// + /// Absolute link to the order in the buyer's account + /// + public string OrderUrl { 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/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/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/Core/Grand.Domain/Orders/OrderApproval.cs b/src/Core/Grand.Domain/Orders/OrderApproval.cs new file mode 100644 index 0000000000..3d6e050ffc --- /dev/null +++ b/src/Core/Grand.Domain/Orders/OrderApproval.cs @@ -0,0 +1,67 @@ +namespace Grand.Domain.Orders; + +/// +/// Represents a request to approve an order a company sub-account placed +/// +public class OrderApproval : BaseEntity +{ + /// + /// Gets or sets the store identifier the order 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 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 awaiting the decision; set at creation + /// + 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/OrderApprovalSettings.cs b/src/Core/Grand.Domain/Orders/OrderApprovalSettings.cs new file mode 100644 index 0000000000..c82704a445 --- /dev/null +++ b/src/Core/Grand.Domain/Orders/OrderApprovalSettings.cs @@ -0,0 +1,15 @@ +using Grand.Domain.Configuration; + +namespace Grand.Domain.Orders; + +/// +/// Settings of orders that wait for a company approver +/// +public class OrderApprovalSettings : ISettings +{ + /// + /// Gets or sets the system names of the payment methods offered for orders that need an approval; + /// card capture stays away from orders nobody has approved + /// + public List PaymentMethodSystemNames { 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..87600385ca --- /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; the order was released + /// + Approved = 10, + + /// + /// Rejected by an approver + /// + Rejected = 20, + + /// + /// Withdrawn by the buyer + /// + Withdrawn = 30, + + /// + /// Closed because the order was cancelled in the back office before anyone decided + /// + Cancelled = 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/Core/Grand.Domain/Orders/OrderStatusSystem.cs b/src/Core/Grand.Domain/Orders/OrderStatusSystem.cs index 642c7d26eb..026681d5a1 100644 --- a/src/Core/Grand.Domain/Orders/OrderStatusSystem.cs +++ b/src/Core/Grand.Domain/Orders/OrderStatusSystem.cs @@ -5,6 +5,11 @@ namespace Grand.Domain.Orders; /// public enum OrderStatusSystem { + /// + /// Placed by a company buyer and waiting for an approver's decision + /// + AwaitingApproval = 5, + /// /// Pending /// diff --git a/src/Modules/Grand.Module.Installer/Services/InstallDataOrderStatus.cs b/src/Modules/Grand.Module.Installer/Services/InstallDataOrderStatus.cs index dc79125d5f..2c2d5a77d5 100644 --- a/src/Modules/Grand.Module.Installer/Services/InstallDataOrderStatus.cs +++ b/src/Modules/Grand.Module.Installer/Services/InstallDataOrderStatus.cs @@ -30,6 +30,12 @@ protected virtual Task InstallOrderStatus() StatusId = 40, Name = "Cancelled", DisplayOrder = 3 + }, + new() { + IsSystem = true, + StatusId = 5, + Name = "Awaiting approval", + DisplayOrder = 5 } }; statuses.ForEach(x => _orderStatusRepository.Insert(x)); diff --git a/src/Modules/Grand.Module.Installer/Services/InstallDataSettings.cs b/src/Modules/Grand.Module.Installer/Services/InstallDataSettings.cs index b772171f4d..f232ca2ac7 100644 --- a/src/Modules/Grand.Module.Installer/Services/InstallDataSettings.cs +++ b/src/Modules/Grand.Module.Installer/Services/InstallDataSettings.cs @@ -437,6 +437,10 @@ await _settingRepository.SaveSetting(new PaymentSettings { SkipPaymentInfo = false }); + await _settingRepository.SaveSetting(new OrderApprovalSettings { + PaymentMethodSystemNames = ["Payments.CashOnDelivery"] + }); + await _settingRepository.SaveSetting(new TaxSettings { TaxBasedOn = TaxBasedOn.BillingAddress, TaxDisplayType = TaxDisplayType.ExcludingTax, diff --git a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs index 3a45b80638..64a89f9fea 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,17 @@ 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"); + //the request of an order: read at placement and when the order is cancelled + await dbContext.CreateIndex(_orderApprovalRepository, + OrderBuilder.Create().Ascending(x => x.OrderId), "OrderId"); + //merchandise return await dbContext.CreateIndex(_merchandiseReturnRepository, OrderBuilder.Create().Ascending(x => x.ReturnNumber), "ReturnNumber", true); 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..6538971878 --- /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( + "{% if OrderApproval.OrderNumber != '' %}Order #{{OrderApproval.OrderNumber}}{% else %}An order{% endif %} is waiting for your approval.", + "Order approval requested", + @" +

Hello {{Customer.FullName | Escape}},

+

{{OrderApproval.BuyerName | Escape}} ({{OrderApproval.BuyerEmail | Escape}}) asked you to approve order{% if OrderApproval.OrderNumber != '' %} #{{OrderApproval.OrderNumber}}{% endif %} at {{Store.Name}}. The order is on hold 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 is {{OrderApproval.Status}}", + Body = Layout( + "Your order approval request has been decided.", + "Order approval {{OrderApproval.Status}}", + @" +

Hello {{Customer.FullName | Escape}},

+

Your request to approve order{% if OrderApproval.OrderNumber != '' %} #{{OrderApproval.OrderNumber}}{% endif %} 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.OrderUrl}}", "View your order") + @" +

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/Modules/Grand.Module.Migration/Migrations/2.5/MigrationOrderApproval.cs b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationOrderApproval.cs new file mode 100644 index 0000000000..48ec61e9eb --- /dev/null +++ b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationOrderApproval.cs @@ -0,0 +1,256 @@ +using Grand.Data; +using Grand.Domain.Configuration; +using Grand.Domain.Messages; +using Grand.Domain.Orders; +using Grand.Infrastructure.Caching; +using Grand.Infrastructure.Caching.Constants; +using Grand.Infrastructure.Migrations; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace Grand.Module.Migration.Migrations._2._5; + +/// +/// Prepares an existing installation for B2B order approval: the indexes of the OrderApproval collection, the +/// two e-mails it sends, the "Awaiting approval" order status and the default payment methods allowed for such +/// orders. The installer creates the same for new installations (it is not referenced from here, +/// so the template bodies are repeated below - a test keeps their subjects and tokens equal). +/// Idempotent: a template, status or setting that exists (the operator may have created or edited one) is +/// never touched. +/// +public class MigrationOrderApproval : IMigration +{ + private const string RequestedTemplateName = "OrderApproval.Requested"; + private const string DecidedTemplateName = "OrderApproval.Decided"; + + public int Priority => 1; + public DbVersion Version => new(2, 5); + public Guid Identity => new("b7c3e5a1-48d2-4f60-9b17-3a5e8d02c6f4"); + public string Name => "Order approval indexes, order status, settings and message templates 2.5"; + + public async Task UpgradeProcess(IServiceProvider serviceProvider) + { + var orderApprovalRepository = serviceProvider.GetRequiredService>(); + var templateRepository = serviceProvider.GetRequiredService>(); + var orderStatusRepository = serviceProvider.GetRequiredService>(); + var settingRepository = serviceProvider.GetRequiredService>(); + var databaseContext = serviceProvider.GetRequiredService(); + var logService = serviceProvider.GetRequiredService>(); + + try + { + //the same indexes, with the same names, as InstallationService creates + await databaseContext.CreateIndex(orderApprovalRepository, + OrderBuilder.Create().Ascending(x => x.CompanyOwnerId).Ascending(x => x.Status) + .Descending(x => x.CreatedOnUtc), "CompanyOwnerId_Status_CreatedOnUtc"); + await databaseContext.CreateIndex(orderApprovalRepository, + OrderBuilder.Create().Ascending(x => x.CustomerId).Ascending(x => x.StoreId) + .Ascending(x => x.Status), "CustomerId_StoreId_Status"); + await databaseContext.CreateIndex(orderApprovalRepository, + OrderBuilder.Create().Ascending(x => x.OrderId), "OrderId"); + + var emailAccountId = await GetEmailAccountId(serviceProvider); + var inserted = false; + foreach (var template in Templates(emailAccountId)) + { + var name = template.Name; + var existing = await templateRepository.FirstOrDefaultAsync( + templateRepository.Table.Where(x => x.Name == name)); + if (existing != null) continue; + + await templateRepository.InsertAsync(template); + inserted = true; + } + + var statusInserted = await InsertAwaitingApprovalStatus(orderStatusRepository); + await InsertSettings(settingRepository); + + //a lookup made before the upgrade may have cached "no such template" or the list of statuses without it + var cache = serviceProvider.GetService(); + if (cache != null) + { + if (inserted) + await cache.RemoveByPrefix(CacheKey.MESSAGETEMPLATES_PATTERN_KEY); + if (statusInserted) + await cache.RemoveByPrefix(CacheKey.ORDER_STATUS_ALL); + } + } + catch (Exception ex) + { + logService.LogError(ex, "UpgradeProcess - OrderApproval (2.5)"); + return false; + } + + return true; + } + + /// + /// Status 5 is reserved for the system: statuses an administrator adds get the highest id plus 10, so it never + /// collides with a custom one. A row that has this id already is left as it is. + /// + private static async Task InsertAwaitingApprovalStatus(IRepository repository) + { + const int statusId = (int)OrderStatusSystem.AwaitingApproval; + var existing = await repository.FirstOrDefaultAsync(repository.Table.Where(x => x.StatusId == statusId)); + if (existing != null) return false; + + await repository.InsertAsync(new OrderStatus { + StatusId = statusId, Name = "Awaiting approval", DisplayOrder = 5, IsSystem = true + }); + return true; + } + + /// + /// The global setting, as the installer saves it; an existing one (the operator may have chosen other + /// methods already) is never overwritten + /// + private static async Task InsertSettings(IRepository repository) + { + var name = nameof(OrderApprovalSettings).ToLowerInvariant(); + var existing = await repository.FirstOrDefaultAsync( + repository.Table.Where(x => x.Name == name && (x.StoreId == null || x.StoreId == ""))); + if (existing != null) return; + + await repository.InsertAsync(SettingExtensions.CreateSetting(new OrderApprovalSettings { + PaymentMethodSystemNames = ["Payments.CashOnDelivery"] + })); + } + + /// + /// The store's default e-mail account when it still exists, otherwise the first one. Empty when there is none: + /// the sender then falls back to the default account of the settings at send time + /// + private static async Task GetEmailAccountId(IServiceProvider serviceProvider) + { + var emailAccountRepository = serviceProvider.GetRequiredService>(); + var settingRepository = serviceProvider.GetRequiredService>(); + + var settingName = nameof(EmailAccountSettings).ToLowerInvariant(); + var setting = await settingRepository.FirstOrDefaultAsync( + settingRepository.Table.Where(x => x.Name == settingName && (x.StoreId == null || x.StoreId == ""))); + if (!string.IsNullOrEmpty(setting?.Metadata)) + { + var defaultId = JsonSerializer.Deserialize(setting.Metadata)?.DefaultEmailAccountId; + if (!string.IsNullOrEmpty(defaultId)) + { + var account = await emailAccountRepository.FirstOrDefaultAsync( + emailAccountRepository.Table.Where(x => x.Id == defaultId)); + if (account != null) return account.Id; + } + } + + var first = await emailAccountRepository.FirstOrDefaultAsync(emailAccountRepository.Table.OrderBy(x => x.Id)); + return first?.Id ?? string.Empty; + } + + /// + /// The two templates, as the installer seeds them: same names, subjects and tokens + /// + public static IEnumerable Templates(string emailAccountId) + { + yield return new MessageTemplate { + Name = RequestedTemplateName, + Subject = "{{Store.Name}}: order approval requested by {{OrderApproval.BuyerName}}", + Body = Layout( + "{% if OrderApproval.OrderNumber != '' %}Order #{{OrderApproval.OrderNumber}}{% else %}An order{% endif %} is waiting for your approval.", + "Order approval requested", + @" +

Hello {{Customer.FullName | Escape}},

+

{{OrderApproval.BuyerName | Escape}} ({{OrderApproval.BuyerEmail | Escape}}) asked you to approve order{% if OrderApproval.OrderNumber != '' %} #{{OrderApproval.OrderNumber}}{% endif %} at {{Store.Name}}. The order is on hold until you decide.

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

The order contains:

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

Order total: {{OrderApproval.Total}}

"), + IsActive = true, + EmailAccountId = emailAccountId + }; + yield return new MessageTemplate { + Name = DecidedTemplateName, + Subject = "{{Store.Name}}: your order approval request is {{OrderApproval.Status}}", + Body = Layout( + "Your order approval request has been decided.", + "Order approval {{OrderApproval.Status}}", + @" +

Hello {{Customer.FullName | Escape}},

+

Your request to approve order{% if OrderApproval.OrderNumber != '' %} #{{OrderApproval.OrderNumber}}{% endif %} 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.OrderUrl}}", "View your order") + @" +

The order contains:

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

Order total: {{OrderApproval.Total}}

+

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

"), + IsActive = true, + EmailAccountId = emailAccountId + }; + } + + private static string Layout(string preheader, string heading, string content) + { + return $@" + + + + +{{{{Store.Name}}}} + + +
{preheader}
+ + + + +
+ + + + + + + + + + + + + +
+{{{{Store.Name}}}} +
+

{heading}

+
+{content} +
+{{% if Store.CompanyName != null and Store.CompanyName != '' %}} +{{{{Store.CompanyName}}}}
+{{% endif %}} +{{% if Store.CompanyAddress != null and Store.CompanyAddress != '' %}} +{{{{Store.CompanyAddress}}}}
+{{% endif %}} +{{% if Store.CompanyPhoneNumber != null and Store.CompanyPhoneNumber != '' %}} +{{{{Store.CompanyPhoneNumber}}}}
+{{% endif %}} +{{% if Store.CompanyEmail != null and Store.CompanyEmail != '' %}} +{{{{Store.CompanyEmail}}}} +{{% endif %}} +
+
+ +"; + } + + private static string Button(string url, string text) + { + return $@" + + + + +
+{text} +
"; + } +} diff --git a/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/CheckOrderStatusCommandHandlerTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/CheckOrderStatusCommandHandlerTests.cs index 86a4d80969..01a1862a41 100644 --- a/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/CheckOrderStatusCommandHandlerTests.cs +++ b/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/CheckOrderStatusCommandHandlerTests.cs @@ -3,6 +3,7 @@ using Grand.Business.Core.Interfaces.Checkout.Orders; using Grand.Domain.Orders; using Grand.Domain.Payments; +using Grand.Domain.Shipping; using Grand.Mediator; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -26,6 +27,25 @@ public void Init() _handler = new CheckOrderStatusCommandHandler(_mediatorMock.Object, _orderSerivce.Object, _settings); } + [TestMethod] + public async Task Handle_OrderAwaitingApproval_IsNeverChanged() + { + var order = new Order { + PaymentStatusId = PaymentStatus.Paid, + PaidDateUtc = null, + ShippingStatusId = ShippingStatus.ShippingNotRequired, + OrderStatusId = (int)OrderStatusSystem.AwaitingApproval + }; + + var result = await _handler.Handle(new CheckOrderStatusCommand { Order = order }, default); + + Assert.IsTrue(result); + Assert.AreEqual((int)OrderStatusSystem.AwaitingApproval, order.OrderStatusId); + Assert.IsNull(order.PaidDateUtc); + _orderSerivce.Verify(c => c.UpdateOrder(It.IsAny()), Times.Never); + _mediatorMock.Verify(c => c.Send(It.IsAny(), default), Times.Never); + } + [TestMethod] public async Task Handle_InvokeExpectedMethods() { diff --git a/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/MarkAsPaidCommandHandlerTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/MarkAsPaidCommandHandlerTests.cs index 7970037b65..50f46b48b6 100644 --- a/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/MarkAsPaidCommandHandlerTests.cs +++ b/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/MarkAsPaidCommandHandlerTests.cs @@ -1,8 +1,13 @@ using Grand.Business.Checkout.Commands.Handlers.Orders; using Grand.Business.Core.Commands.Checkout.Orders; +using Grand.Business.Core.Events.Checkout.Orders; using Grand.Business.Core.Interfaces.Checkout.Orders; using Grand.Business.Core.Interfaces.Checkout.Payments; +using Grand.Business.Core.Interfaces.Common.Pdf; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.Messages; using Grand.Business.Core.Queries.Checkout.Orders; +using Grand.Domain.Localization; using Grand.Domain.Orders; using Grand.Domain.Payments; using Grand.Mediator; @@ -48,4 +53,34 @@ public async Task HandleTest() _orderServiceMock.Verify(c => c.UpdateOrder(It.IsAny()), Times.Once); _paymentTransactionMock.Verify(c => c.UpdatePaymentTransaction(It.IsAny()), Times.Once); } + + [TestMethod] + public async Task Handle_OrderAwaitingApproval_StaysAwaitingAndRunsNoPaidProcessing() + { + //Arrange: the real status check and paid processing sit behind the mediator + var order = new Order { OrderStatusId = (int)OrderStatusSystem.AwaitingApproval, OrderTotal = 10 }; + var messageProvider = new Mock(); + var customerService = new Mock(); + var paidMediator = new Mock(); + var checkStatus = new CheckOrderStatusCommandHandler(paidMediator.Object, _orderServiceMock.Object, + new OrderSettings()); + var paid = new ProcessOrderPaidCommandHandler(messageProvider.Object, customerService.Object, + paidMediator.Object, new Mock().Object, new OrderSettings(), new LanguageSettings()); + _mediatorMock.Setup(x => x.Send(It.IsAny(), default)) + .ReturnsAsync(true); + _mediatorMock.Setup(x => x.Send(It.IsAny(), default)) + .Returns((CheckOrderStatusCommand c, CancellationToken t) => checkStatus.Handle(c, t)); + _mediatorMock.Setup(x => x.Send(It.IsAny(), default)) + .Returns((ProcessOrderPaidCommand c, CancellationToken t) => paid.Handle(c, t)); + _orderServiceMock.Setup(x => x.GetOrderByGuid(It.IsAny())).ReturnsAsync(order); + + //Act + await _handler.Handle(new MarkAsPaidCommand { PaymentTransaction = new PaymentTransaction() }, default); + + //Assert + Assert.AreEqual(PaymentStatus.Paid, order.PaymentStatusId); + Assert.AreEqual((int)OrderStatusSystem.AwaitingApproval, order.OrderStatusId); + paidMediator.Verify(x => x.Publish(It.IsAny(), It.IsAny()), Times.Never); + messageProvider.VerifyNoOtherCalls(); + } } \ No newline at end of file diff --git a/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/OrderNotificationCommandHandlerTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/OrderNotificationCommandHandlerTests.cs index 7c869832c1..369f6c1d33 100644 --- a/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/OrderNotificationCommandHandlerTests.cs +++ b/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/OrderNotificationCommandHandlerTests.cs @@ -5,6 +5,8 @@ using Grand.Business.Core.Interfaces.Messages; using Grand.Domain.Customers; using Grand.Domain.Localization; +using Grand.Domain.Stores; +using Grand.Domain.Vendors; using Grand.Domain.Orders; using Grand.Mediator; using Microsoft.Extensions.Logging; @@ -46,6 +48,79 @@ await _handler.Handle(new OrderNotificationCommand { n.OrderId == "order" && n.Note.Contains("staff@shop.example") && n.Note.Contains("impersonating"))), Times.Once); } + [TestMethod] + public async Task Handle_ByDefault_AddsTheOrderPlacedNote() + { + await _handler.Handle(new OrderNotificationCommand { Order = new Order { Id = "order" }, Customer = new Customer() }, + CancellationToken.None); + + _orderServiceMock.Verify(x => x.InsertOrderNote(It.Is(n => + n.OrderId == "order" && n.Note == "Order placed")), Times.Once); + } + + [TestMethod] + public async Task Handle_WithoutOrderPlacedNote_AddsNoNoteButStillNotifies() + { + var customer = new Customer(); + var order = new Order { Id = "order", CustomerLanguageId = "en" }; + + await _handler.Handle(new OrderNotificationCommand { + Order = order, Customer = customer, AddOrderPlacedNote = false, NotifyCustomer = false + }, CancellationToken.None); + + _orderServiceMock.Verify(x => x.InsertOrderNote(It.IsAny()), Times.Never); + _orderMessageProviderServiceMock.Verify(x => x.SendOrderPlacedStoreOwnerMessage(order, customer, + It.IsAny(), It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task Handle_ByDefault_NotifiesStoreOwnerAndVendors() + { + var customer = new Customer(); + var order = new Order { Id = "order", CustomerLanguageId = "en" }; + + await _handler.Handle(new OrderNotificationCommand { Order = order, Customer = customer }, CancellationToken.None); + + _orderMessageProviderServiceMock.Verify(x => x.SendOrderPlacedStoreOwnerMessage(order, customer, + It.IsAny(), It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task Handle_WithoutStoreOwnerAndVendors_SendsOnlyTheCustomerMessage() + { + var customer = new Customer(); + var order = new Order { Id = "order", CustomerLanguageId = "en" }; + order.OrderItems.Add(new OrderItem { VendorId = "vendor" }); + + await _handler.Handle(new OrderNotificationCommand { + Order = order, Customer = customer, NotifyStoreOwnerAndVendors = false + }, CancellationToken.None); + + _orderMessageProviderServiceMock.Verify(x => x.SendOrderPlacedStoreOwnerMessage(It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _orderMessageProviderServiceMock.Verify(x => x.SendOrderPlacedVendorMessage(It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _orderMessageProviderServiceMock.Verify(x => x.SendOrderPlacedCustomerMessage(order, customer, "en", + It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task Handle_WithoutCustomerNotification_SendsOnlyTheStoreOwnerMessage() + { + var customer = new Customer(); + var order = new Order { Id = "order", CustomerLanguageId = "en" }; + + await _handler.Handle(new OrderNotificationCommand { + Order = order, Customer = customer, NotifyCustomer = false + }, CancellationToken.None); + + _orderMessageProviderServiceMock.Verify(x => x.SendOrderPlacedCustomerMessage(It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny()), Times.Never); + _orderMessageProviderServiceMock.Verify(x => x.SendOrderPlacedStoreOwnerMessage(order, customer, + It.IsAny(), It.IsAny()), Times.Once); + } + [TestMethod] public async Task Handle_SendsTheCustomerMessageForTheCommandCustomer() { diff --git a/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/PlaceOrderCommandHandlerApprovalTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/PlaceOrderCommandHandlerApprovalTests.cs new file mode 100644 index 0000000000..63982eb4fc --- /dev/null +++ b/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/PlaceOrderCommandHandlerApprovalTests.cs @@ -0,0 +1,213 @@ +using Grand.Business.Checkout.Commands.Handlers.Orders; +using Grand.Business.Core.Commands.Checkout.Orders; +using Grand.Business.Core.Commands.Messages.Common; +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.GiftVouchers; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Checkout.Payments; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.Messages; +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.Payments; +using Grand.Domain.Shipping; +using Grand.Domain.Stores; +using Grand.Domain.Tax; +using Grand.Infrastructure.Configuration; +using Grand.Mediator; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Business.Checkout.Tests.Commands.Handlers.Orders; + +[TestClass] +public class PlaceOrderCommandHandlerApprovalTests +{ + private const string NotAllowedText = "Payment method not allowed for approval"; + private Mock _approvalService; + private PlaceOrderCommand _command; + private PlaceOrderContainer _details; + private TestHandler _handler; + private Mock _mediator; + + [TestInitialize] + public void Init() + { + _approvalService = new Mock(); + _mediator = new Mock(); + var translation = new Mock(); + translation.Setup(x => x.GetResource("Checkout.OrderApproval.PaymentMethodNotAllowed", "pl", It.IsAny(), + It.IsAny())).Returns(NotAllowedText); + + var customer = new Customer { Id = "buyer", OwnerId = "owner" }; + var store = new Store { Id = "store" }; + var context = new CalculationContext(customer, store, new Currency { CurrencyCode = "EUR", Rate = 1 }, + TaxDisplayType.IncludingTax); + _command = new PlaceOrderCommand { + Context = context, + Language = new Language { Id = "pl" }, + Host = new DomainHost { Url = "https://shop.example/" } + }; + _details = new PlaceOrderContainer { + Context = context, + Customer = customer, + Store = store, + Language = new Language { Id = "pl" }, + Currency = new Currency { CurrencyCode = "EUR", Rate = 1 }, + PrimaryCurrencyCode = "EUR", + PaymentMethodSystemName = "Payments.Card", + OrderTotal = 500 + }; + + _handler = new TestHandler(_details, _approvalService, _mediator, translation); + } + + private void Requires(bool required) + { + _approvalService.Setup(x => x.RequiresApproval(_details.Cart, _command.Context)) + .ReturnsAsync(new OrderApprovalCheckoutState(required, 100, 500)); + } + + [TestMethod] + public async Task Handle_ApprovalRequiredAndMethodNotAllowed_AddsErrorAndSkipsPayment() + { + Requires(true); + _approvalService.Setup(x => x.IsPaymentMethodAllowed("Payments.Card")).Returns(false); + + var result = await _handler.Handle(_command, CancellationToken.None); + + CollectionAssert.Contains(result.Errors.ToList(), NotAllowedText); + Assert.IsNull(result.PlacedOrder); + Assert.AreEqual(0, _handler.PaymentCalls); + _mediator.Verify(x => x.Send(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Handle_ApprovalRequiredAndMethodAllowed_PlacesAwaitingOrderWithoutStoreOwnerAndVendorMails() + { + Requires(true); + _approvalService.Setup(x => x.IsPaymentMethodAllowed("Payments.CashOnDelivery")).Returns(true); + _details.PaymentMethodSystemName = "Payments.CashOnDelivery"; + OrderNotificationCommand sent = null; + _mediator.Setup(x => x.Send(It.IsAny(), It.IsAny())) + .Callback((c, _) => sent = c); + + var result = await _handler.Handle(_command, CancellationToken.None); + + Assert.IsEmpty(result.Errors); + Assert.AreEqual(1, _handler.PaymentCalls); + Assert.AreEqual((int)OrderStatusSystem.AwaitingApproval, _handler.LastHeader.OrderStatusId); + Assert.IsNotNull(sent); + Assert.IsFalse(sent.NotifyStoreOwnerAndVendors); + } + + [TestMethod] + public async Task Handle_ApprovalNotRequired_PlacesPendingOrderAndNotifiesEveryone() + { + Requires(false); + OrderNotificationCommand sent = null; + _mediator.Setup(x => x.Send(It.IsAny(), It.IsAny())) + .Callback((c, _) => sent = c); + + var result = await _handler.Handle(_command, CancellationToken.None); + + Assert.IsEmpty(result.Errors); + Assert.AreEqual((int)OrderStatusSystem.Pending, _handler.LastHeader.OrderStatusId); + Assert.IsTrue(sent.NotifyStoreOwnerAndVendors); + _approvalService.Verify(x => x.IsPaymentMethodAllowed(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Handle_PaidOrderAwaitingApproval_DoesNotProcessItAsPaid() + { + Requires(true); + _approvalService.Setup(x => x.IsPaymentMethodAllowed("Payments.Card")).Returns(true); + _handler.PaidResult = true; + + await _handler.Handle(_command, CancellationToken.None); + + _mediator.Verify(x => x.Send(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Handle_PaidOrderNotAwaitingApproval_ProcessesItAsPaid() + { + Requires(false); + _handler.PaidResult = true; + + await _handler.Handle(_command, CancellationToken.None); + + _mediator.Verify(x => x.Send(It.IsAny(), It.IsAny()), Times.Once); + } + + private class TestHandler : PlaceOrderCommandHandler + { + private readonly PlaceOrderContainer _details; + + public TestHandler(PlaceOrderContainer details, Mock approval, Mock mediator, + Mock translation) : base(new Mock().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, + new Mock().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, + new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, mediator.Object, + new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, approval.Object, + translation.Object, new ShippingSettings(), new ShoppingCartSettings(), new PaymentSettings(), + new OrderSettings(), new OidcLoginConfig(), new TaxSettings()) + { + _details = details; + } + + public int PaymentCalls { get; private set; } + public bool PaidResult { get; set; } + public Order LastHeader { get; private set; } + + protected override Task PreparePlaceOrderDetails(PlaceOrderCommand command) + { + return Task.FromResult(_details); + } + + protected override Task<(ProcessPaymentResult paymentResult, PaymentTransaction paymentTransaction)> + PrepareProcessPayment(PlaceOrderContainer details) + { + PaymentCalls++; + return Task.FromResult(( + new ProcessPaymentResult { + NewPaymentTransactionStatus = PaidResult ? TransactionStatus.Paid : TransactionStatus.Pending, + PaidAmount = PaidResult ? details.OrderTotal : 0 + }, + new PaymentTransaction { + Id = "tx", StoreId = "store", PaymentMethodSystemName = details.PaymentMethodSystemName + })); + } + + protected override Order PrepareOrderHeader(PaymentTransaction paymentTransaction, + ProcessPaymentResult processPaymentResult, PlaceOrderContainer details) + { + LastHeader = base.PrepareOrderHeader(paymentTransaction, processPaymentResult, details); + return LastHeader; + } + + protected override Task SaveOrderDetails(PlaceOrderContainer details, Order order) + { + return Task.FromResult(order); + } + } +} diff --git a/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/PlaceOrderCommandHandlerTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/PlaceOrderCommandHandlerTests.cs index fc7c507da7..503de30167 100644 --- a/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/PlaceOrderCommandHandlerTests.cs +++ b/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/PlaceOrderCommandHandlerTests.cs @@ -56,7 +56,8 @@ public void Init() new Mock().Object, new Mock().Object, currencyService.Object, new Mock().Object, new Mock().Object, new Mock().Object, new Mock().Object, - new Mock().Object, new Mock().Object, new ShippingSettings(), + new Mock().Object, new Mock().Object, new Mock().Object, + new Mock().Object, new ShippingSettings(), new ShoppingCartSettings(), new PaymentSettings(), new OrderSettings(), new OidcLoginConfig(), new TaxSettings()); } diff --git a/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/ProcessOrderPaidCommandHandlerTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/ProcessOrderPaidCommandHandlerTests.cs new file mode 100644 index 0000000000..2b0a0708a4 --- /dev/null +++ b/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/ProcessOrderPaidCommandHandlerTests.cs @@ -0,0 +1,60 @@ +using Grand.Business.Checkout.Commands.Handlers.Orders; +using Grand.Business.Core.Commands.Checkout.Orders; +using Grand.Business.Core.Events.Checkout.Orders; +using Grand.Business.Core.Interfaces.Common.Pdf; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.Messages; +using Grand.Domain.Customers; +using Grand.Domain.Localization; +using Grand.Domain.Orders; +using Grand.Mediator; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Business.Checkout.Tests.Commands.Handlers.Orders; + +[TestClass] +public class ProcessOrderPaidCommandHandlerTests +{ + private Mock _customerServiceMock; + private ProcessOrderPaidCommandHandler _handler; + private Mock _mediatorMock; + private Mock _messageProviderMock; + + [TestInitialize] + public void Init() + { + _customerServiceMock = new Mock(); + _customerServiceMock.Setup(x => x.GetCustomerById(It.IsAny())).ReturnsAsync(new Customer()); + _mediatorMock = new Mock(); + _messageProviderMock = new Mock(); + _handler = new ProcessOrderPaidCommandHandler(_messageProviderMock.Object, _customerServiceMock.Object, + _mediatorMock.Object, new Mock().Object, new OrderSettings(), new LanguageSettings()); + } + + [TestMethod] + public async Task Handle_OrderAwaitingApproval_PublishesAndSendsNothing() + { + var order = new Order { OrderTotal = 10, OrderStatusId = (int)OrderStatusSystem.AwaitingApproval }; + + var result = await _handler.Handle(new ProcessOrderPaidCommand { Order = order }, CancellationToken.None); + + Assert.IsTrue(result); + _mediatorMock.Verify(x => x.Publish(It.IsAny(), It.IsAny()), Times.Never); + _messageProviderMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task Handle_PendingOrder_PublishesAndSendsPaidMessages() + { + var order = new Order { OrderTotal = 10, OrderStatusId = (int)OrderStatusSystem.Pending }; + + await _handler.Handle(new ProcessOrderPaidCommand { Order = order }, CancellationToken.None); + + _mediatorMock.Verify(x => x.Publish(It.IsAny(), It.IsAny()), Times.Once); + _messageProviderMock.Verify(x => x.SendOrderPaidCustomerMessage(order, It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>()), Times.Once); + _messageProviderMock.Verify(x => x.SendOrderPaidStoreOwnerMessage(order, It.IsAny(), + It.IsAny()), Times.Once); + } +} diff --git a/src/Tests/Grand.Business.Checkout.Tests/Events/Orders/OrderCancelledOrderApprovalHandlerTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Events/Orders/OrderCancelledOrderApprovalHandlerTests.cs new file mode 100644 index 0000000000..a5c5b3420d --- /dev/null +++ b/src/Tests/Grand.Business.Checkout.Tests/Events/Orders/OrderCancelledOrderApprovalHandlerTests.cs @@ -0,0 +1,80 @@ +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.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Business.Checkout.Tests.Events.Orders; + +[TestClass] +public class OrderCancelledOrderApprovalHandlerTests +{ + private Mock _approvalService; + private OrderCancelledOrderApprovalHandler _handler; + private Mock> _logger; + private Order _order; + + [TestInitialize] + public void Init() + { + _approvalService = new Mock(); + _logger = new Mock>(); + _handler = new OrderCancelledOrderApprovalHandler(_approvalService.Object, _logger.Object); + _order = new Order { Id = "order", OrderNumber = 7, OrderStatusId = (int)OrderStatusSystem.Cancelled }; + } + + [TestMethod] + public async Task Handle_PendingApproval_ClosesItAsCancelled() + { + var approval = new OrderApproval { Id = "a1", OrderId = "order", Status = OrderApprovalStatus.Pending }; + _approvalService.Setup(x => x.GetByOrderId("order")).ReturnsAsync(approval); + + await _handler.Handle(new OrderCancelledEvent(_order), CancellationToken.None); + + Assert.AreEqual(OrderApprovalStatus.Cancelled, approval.Status); + Assert.IsNotNull(approval.DecidedOnUtc); + Assert.IsNull(approval.DecidedByCustomerId); + _approvalService.Verify(x => x.Update(approval), Times.Once); + } + + [TestMethod] + [DataRow(OrderApprovalStatus.Approved)] + [DataRow(OrderApprovalStatus.Rejected)] + [DataRow(OrderApprovalStatus.Withdrawn)] + [DataRow(OrderApprovalStatus.Cancelled)] + public async Task Handle_DecidedApproval_IsLeftAlone(OrderApprovalStatus status) + { + var approval = new OrderApproval { Id = "a1", OrderId = "order", Status = status }; + _approvalService.Setup(x => x.GetByOrderId("order")).ReturnsAsync(approval); + + await _handler.Handle(new OrderCancelledEvent(_order), CancellationToken.None); + + Assert.AreEqual(status, approval.Status); + _approvalService.Verify(x => x.Update(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Handle_OrderWithoutApproval_DoesNothing() + { + _approvalService.Setup(x => x.GetByOrderId("order")).ReturnsAsync((OrderApproval)null); + + await _handler.Handle(new OrderCancelledEvent(_order), CancellationToken.None); + + _approvalService.Verify(x => x.Update(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Handle_SaveFails_LogsAndCompletes() + { + _approvalService.Setup(x => x.GetByOrderId("order")) + .ReturnsAsync(new OrderApproval { Id = "a1", OrderId = "order", Status = OrderApprovalStatus.Pending }); + _approvalService.Setup(x => x.Update(It.IsAny())).ThrowsAsync(new Exception("db down")); + + await _handler.Handle(new OrderCancelledEvent(_order), CancellationToken.None); + + _logger.Verify(x => x.Log(LogLevel.Error, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>()), Times.Once); + } +} 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..27441f645d --- /dev/null +++ b/src/Tests/Grand.Business.Checkout.Tests/Events/Orders/OrderPlacedOrderApprovalHandlerTests.cs @@ -0,0 +1,216 @@ +using Grand.Business.Checkout.Events.Orders; +using Grand.Business.Core.Events.Checkout.Orders; +using Grand.Business.Core.Interfaces.Catalog.Prices; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.Messages; +using Grand.Domain; +using Grand.Domain.Catalog; +using Grand.Domain.Customers; +using Grand.Domain.Directory; +using Grand.Domain.Orders; +using Grand.Domain.Stores; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Business.Checkout.Tests.Events.Orders; + +[TestClass] +public class OrderPlacedOrderApprovalHandlerTests +{ + private Mock _approvalService; + private Customer _buyer; + private Mock _customerService; + private OrderPlacedOrderApprovalHandler _handler; + private Mock _messages; + private Order _order; + private Mock> _logger; + private Customer _owner; + private OrderApproval _inserted; + + [TestInitialize] + public void Init() + { + _approvalService = new Mock(); + _approvalService.Setup(x => x.Insert(It.IsAny())).Callback(a => _inserted = a) + .Returns(Task.CompletedTask); + _customerService = new Mock(); + _messages = new Mock(); + + var store = new Store { Id = "store" }; + var storeService = new Mock(); + storeService.Setup(x => x.GetStoreById("store")).ReturnsAsync(store); + + var product = new Product { Id = "p1", Name = "Chair" }; + var productService = new Mock(); + productService.Setup(x => x.GetProductById("p1", false)).ReturnsAsync(product); + + var currency = new Currency { CurrencyCode = "EUR" }; + var currencyService = new Mock(); + currencyService.Setup(x => x.GetCurrencyByCode("EUR")).ReturnsAsync(currency); + + var priceFormatter = new Mock(); + priceFormatter.Setup(x => x.FormatPrice(It.IsAny(), currency)) + .Returns((p, _) => $"{p:0.00} EUR"); + + _logger = new Mock>(); + _handler = new OrderPlacedOrderApprovalHandler(_approvalService.Object, _customerService.Object, + _messages.Object, storeService.Object, productService.Object, priceFormatter.Object, + currencyService.Object, _logger.Object); + + _owner = new Customer { Id = "owner", Email = "owner@acme.example", Active = true }; + _buyer = new Customer { + Id = "buyer", OwnerId = "owner", Email = "buyer@acme.example", Active = true, + SubAccountRole = SubAccountRole.Buyer + }; + _customerService.Setup(x => x.GetCustomerById("buyer")).ReturnsAsync(_buyer); + _customerService.Setup(x => x.GetCustomerById("owner")).ReturnsAsync(_owner); + + _order = new Order { + Id = "order", StoreId = "store", CustomerId = "buyer", OwnerId = "owner", OrderTotal = 500, + CustomerCurrencyCode = "EUR", CustomerLanguageId = "en", + OrderStatusId = (int)OrderStatusSystem.AwaitingApproval, + CustomerTaxDisplayTypeId = Grand.Domain.Tax.TaxDisplayType.IncludingTax, + OrderSubtotalInclTax = 450, OrderSubTotalDiscountInclTax = 10, OrderShippingInclTax = 40, + OrderTax = 20, ShippingMethod = "Courier" + }; + _order.OrderItems.Add(new OrderItem { + ProductId = "p1", Sku = "CH-1", Quantity = 2, UnitPriceInclTax = 225, PriceInclTax = 450, + AttributeDescription = "Color: red
Size: L" + }); + } + + private void Staff(params Customer[] staff) + { + _customerService.Setup(x => x.GetAllCustomers(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), "store", "owner", 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(staff.ToList(), 0, int.MaxValue, staff.Length)); + } + + private static Customer Approver(string id, bool active = true, bool deleted = false, string email = null) + { + return new Customer { + Id = id, OwnerId = "owner", SubAccountRole = SubAccountRole.Approver, Active = active, Deleted = deleted, + Email = email ?? $"{id}@acme.example" + }; + } + + [TestMethod] + public async Task Handle_OrderNotAwaitingApproval_DoesNothing() + { + _order.OrderStatusId = (int)OrderStatusSystem.Pending; + + await _handler.Handle(new OrderPlacedEvent(_order), CancellationToken.None); + + _approvalService.Verify(x => x.Insert(It.IsAny()), Times.Never); + _messages.VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task Handle_AwaitingOrder_RecordsThePendingRequestFromTheOrder() + { + Staff(); + + await _handler.Handle(new OrderPlacedEvent(_order), CancellationToken.None); + + Assert.IsNotNull(_inserted); + Assert.AreEqual(OrderApprovalStatus.Pending, _inserted.Status); + Assert.AreEqual("order", _inserted.OrderId); + Assert.AreEqual("owner", _inserted.CompanyOwnerId); + Assert.AreEqual("buyer", _inserted.CustomerId); + Assert.AreEqual("store", _inserted.StoreId); + Assert.AreEqual(500, _inserted.Amount); + Assert.AreEqual("EUR", _inserted.CurrencyCode); + Assert.HasCount(1, _inserted.Items); + var item = _inserted.Items[0]; + Assert.AreEqual("Chair", item.ProductName); + Assert.AreEqual("CH-1", item.Sku); + Assert.AreEqual(2, item.Quantity); + Assert.AreEqual("Color: red, Size: L", item.AttributeDescription); + Assert.AreEqual("225.00 EUR", item.UnitPrice); + Assert.AreEqual("450.00 EUR", item.LineTotal); + Assert.AreEqual("450.00 EUR", _inserted.Summary.SubTotal); + Assert.AreEqual("10.00 EUR", _inserted.Summary.Discount); + Assert.AreEqual("Courier", _inserted.Summary.ShippingMethod); + Assert.AreEqual("40.00 EUR", _inserted.Summary.Shipping); + Assert.AreEqual("20.00 EUR", _inserted.Summary.Tax); + Assert.AreEqual("500.00 EUR", _inserted.Summary.Total); + } + + [TestMethod] + public async Task Handle_AlreadyRecorded_DoesNotInsertAgain() + { + Staff(); + _approvalService.Setup(x => x.GetByOrderId("order")).ReturnsAsync(new OrderApproval()); + + await _handler.Handle(new OrderPlacedEvent(_order), CancellationToken.None); + + _approvalService.Verify(x => x.Insert(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Handle_SendsToTheOwnerAndActiveApproversOnly() + { + var buyerApprover = Approver("buyer"); + var goodApprover = Approver("a1"); + Staff(buyerApprover, goodApprover, Approver("inactive", false), Approver("deleted", deleted: true), + Approver("noemail", email: " "), new Customer { + Id = "plain", OwnerId = "owner", SubAccountRole = SubAccountRole.Buyer, Active = true, + Email = "plain@acme.example" + }); + _buyer.SubAccountRole = SubAccountRole.Approver; + + await _handler.Handle(new OrderPlacedEvent(_order), CancellationToken.None); + + var recipients = new List(); + _messages.Verify(x => x.SendOrderApprovalRequestedMessage(_inserted, _buyer, Capture.In(recipients), + It.IsAny(), "en", null), Times.Exactly(2)); + CollectionAssert.AreEquivalent(new[] { "owner", "a1" }, recipients.Select(x => x.Id).ToList()); + } + + [TestMethod] + public async Task Handle_InsertThrows_CompletesAndLogs() + { + Staff(); + _approvalService.Setup(x => x.Insert(It.IsAny())).ThrowsAsync(new InvalidOperationException("db")); + + await _handler.Handle(new OrderPlacedEvent(_order), CancellationToken.None); + + _logger.Verify(l => l.Log(LogLevel.Error, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>()), Times.Once); + } + + [TestMethod] + public async Task Handle_BuyerMissing_CompletesAndLogs() + { + _customerService.Setup(x => x.GetCustomerById("buyer")).ReturnsAsync((Customer)null); + + await _handler.Handle(new OrderPlacedEvent(_order), CancellationToken.None); + + _approvalService.Verify(x => x.Insert(It.IsAny()), Times.Never); + _logger.Verify(l => l.Log(LogLevel.Error, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>()), Times.Once); + } + + [TestMethod] + public async Task Handle_FailingSend_DoesNotStopTheOthers() + { + Staff(Approver("a1"), Approver("a2")); + _messages.Setup(x => x.SendOrderApprovalRequestedMessage(It.IsAny(), It.IsAny(), + It.Is(c => c.Id == "a1"), It.IsAny(), It.IsAny(), null)) + .ThrowsAsync(new InvalidOperationException("smtp down")); + + await _handler.Handle(new OrderPlacedEvent(_order), CancellationToken.None); + + _messages.Verify(x => x.SendOrderApprovalRequestedMessage(It.IsAny(), _buyer, + It.IsAny(), It.IsAny(), "en", null), Times.Exactly(3)); + } +} diff --git a/src/Tests/Grand.Business.Checkout.Tests/Queries/Handlers/Orders/HandlerTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Queries/Handlers/Orders/HandlerTests.cs index 08ed3da365..f316bbb9da 100644 --- a/src/Tests/Grand.Business.Checkout.Tests/Queries/Handlers/Orders/HandlerTests.cs +++ b/src/Tests/Grand.Business.Checkout.Tests/Queries/Handlers/Orders/HandlerTests.cs @@ -36,6 +36,18 @@ public async Task CanCancelOrderQueryHandler_HandleTest_OrderStatus_Pending_True Assert.IsTrue(result); } + [TestMethod] + public async Task CanCancelOrderQueryHandler_HandleTest_OrderStatus_AwaitingApproval_True() + { + var canCancelOrderQueryHandler = new CanCancelOrderQueryHandler(); + + var result = await canCancelOrderQueryHandler.Handle( + new CanCancelOrderQuery { Order = new Order { OrderStatusId = (int)OrderStatusSystem.AwaitingApproval } }, + CancellationToken.None); + + Assert.IsTrue(result); + } + [TestMethod] public async Task CanCancelOrderQueryHandler_HandleTest_OrderStatus_Processing_False() { 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..d882124f0c --- /dev/null +++ b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderApprovalServiceTests.cs @@ -0,0 +1,384 @@ +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 OrderApprovalSettings _approvalSettings; + 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)); + + _approvalSettings = new OrderApprovalSettings { PaymentMethodSystemNames = ["Payments.CashOnDelivery"] }; + + _service = new OrderApprovalService(_repository, _calculationServiceMock.Object, _customerSettings, + _approvalSettings, _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, string orderId = "order1", + string customerId = BuyerId, string storeId = StoreId, string companyOwnerId = OwnerId, + DateTime? createdOnUtc = null) + { + var approval = new OrderApproval { + StoreId = storeId, + CompanyOwnerId = companyOwnerId, + CustomerId = customerId, + Status = status, + OrderId = orderId, + Amount = 150 + }; + 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 Requires approval + + [TestMethod] + public async Task RequiresApproval_NoOwner_NotRequired() + { + var customer = Buyer(100, ownerId: null); + + var state = await _service.RequiresApproval(Cart(), Context(customer)); + + Assert.IsFalse(state.Required); + _calculationServiceMock.Verify(x => x.GetShoppingCartTotal(It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task RequiresApproval_NoLimit_NotRequired() + { + var state = await _service.RequiresApproval(Cart(), Context(Buyer(null))); + + Assert.IsFalse(state.Required); + } + + [TestMethod] + public async Task RequiresApproval_Approver_NotRequired() + { + var state = await _service.RequiresApproval(Cart(), Context(Buyer(100, SubAccountRole.Approver))); + + Assert.IsFalse(state.Required); + } + + [TestMethod] + public async Task RequiresApproval_FeatureOff_NotRequired() + { + _customerSettings.HideSubAccountsTab = true; + + var state = await _service.RequiresApproval(Cart(), Context(Buyer(100))); + + Assert.IsFalse(state.Required); + } + + [TestMethod] + public async Task RequiresApproval_TotalUnknown_NotRequired() + { + _total = null; + + var state = await _service.RequiresApproval(Cart(), Context(Buyer(100))); + + Assert.IsFalse(state.Required); + Assert.IsNull(state.Total); + Assert.AreEqual(100, state.Limit); + } + + [TestMethod] + public async Task RequiresApproval_TotalAtLimit_NotRequired() + { + _total = 100; + + var state = await _service.RequiresApproval(Cart(), Context(Buyer(100))); + + Assert.IsFalse(state.Required); + Assert.AreEqual(100, state.Total); + } + + [TestMethod] + public async Task RequiresApproval_OverLimit_Required() + { + var state = await _service.RequiresApproval(Cart(), Context(Buyer(100))); + + Assert.IsTrue(state.Required); + Assert.AreEqual(100, state.Limit); + Assert.AreEqual(150, state.Total); + } + + [TestMethod] + public async Task RequiresApproval_ExistingApprovalsDoNotMatter() + { + await AddApproval(OrderApprovalStatus.Approved); + + var state = await _service.RequiresApproval(Cart(), Context(Buyer(100))); + + Assert.IsTrue(state.Required); + } + + #endregion + + #region Payment methods + + [TestMethod] + public void IsPaymentMethodAllowed_ListedMethod_True() + { + _approvalSettings.PaymentMethodSystemNames = ["Payments.CashOnDelivery", "Payments.BankTransfer"]; + + Assert.IsTrue(_service.IsPaymentMethodAllowed("Payments.BankTransfer")); + } + + [TestMethod] + public void IsPaymentMethodAllowed_IgnoresCase() + { + _approvalSettings.PaymentMethodSystemNames = ["Payments.CashOnDelivery"]; + + Assert.IsTrue(_service.IsPaymentMethodAllowed("payments.cashondelivery")); + } + + [TestMethod] + public void IsPaymentMethodAllowed_UnlistedMethod_False() + { + _approvalSettings.PaymentMethodSystemNames = ["Payments.CashOnDelivery"]; + + Assert.IsFalse(_service.IsPaymentMethodAllowed("Payments.StripeCheckout")); + } + + [TestMethod] + public void IsPaymentMethodAllowed_EmptyListOrName_False() + { + _approvalSettings.PaymentMethodSystemNames = []; + Assert.IsFalse(_service.IsPaymentMethodAllowed("Payments.CashOnDelivery")); + + _approvalSettings.PaymentMethodSystemNames = ["Payments.CashOnDelivery"]; + Assert.IsFalse(_service.IsPaymentMethodAllowed(null)); + Assert.IsFalse(_service.IsPaymentMethodAllowed("")); + } + + [TestMethod] + public void IsPaymentMethodAllowed_NullList_False() + { + _approvalSettings.PaymentMethodSystemNames = null; + + Assert.IsFalse(_service.IsPaymentMethodAllowed("Payments.CashOnDelivery")); + } + + #endregion + + #region Reads and writes + + [TestMethod] + public async Task GetById_OtherCompany_Null() + { + var approval = await AddApproval(OrderApprovalStatus.Pending); + + 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); + + var result = await _service.GetById(approval.Id, OwnerId); + + Assert.AreEqual(approval.Id, result?.Id); + } + + [TestMethod] + public async Task GetByOrderId_ReturnsTheOrdersApproval() + { + await AddApproval(OrderApprovalStatus.Pending, "order-other"); + var approval = await AddApproval(OrderApprovalStatus.Pending, "order-2"); + + var result = await _service.GetByOrderId("order-2"); + + Assert.AreEqual(approval.Id, result?.Id); + } + + [TestMethod] + public async Task GetByOrderId_UnknownOrEmpty_Null() + { + await AddApproval(OrderApprovalStatus.Pending, "order-1"); + + Assert.IsNull(await _service.GetByOrderId("nope")); + Assert.IsNull(await _service.GetByOrderId(null)); + Assert.IsNull(await _service.GetByOrderId("")); + } + + [TestMethod] + public async Task GetForCompanyAndCustomer_EmptyId_GiveAnEmptyPage() + { + await _repository.InsertAsync(new OrderApproval { CompanyOwnerId = "", CustomerId = "", StoreId = StoreId }); + + Assert.AreEqual(0, (await _service.GetForCompany("", StoreId, 0, 10)).TotalCount); + Assert.AreEqual(0, (await _service.GetForCompany(OwnerId, "", 0, 10)).TotalCount); + Assert.AreEqual(0, (await _service.GetForCustomer("", StoreId, 0, 10)).TotalCount); + Assert.AreEqual(0, (await _service.GetForCustomer(BuyerId, "", 0, 10)).TotalCount); + } + + [TestMethod] + public async Task GetForCompany_PendingFirst() + { + var now = DateTime.UtcNow; + var oldPending = await AddApproval(OrderApprovalStatus.Pending, createdOnUtc: now.AddDays(-3)); + var newApproved = await AddApproval(OrderApprovalStatus.Approved, createdOnUtc: now); + var oldApproved = await AddApproval(OrderApprovalStatus.Approved, createdOnUtc: now.AddDays(-1)); + var newPending = await AddApproval(OrderApprovalStatus.Pending, createdOnUtc: now.AddDays(-2)); + await AddApproval(OrderApprovalStatus.Pending, companyOwnerId: "other-company"); + await AddApproval(OrderApprovalStatus.Pending, 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, 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, createdOnUtc: now.AddDays(-1)); + var newer = await AddApproval(OrderApprovalStatus.Pending, createdOnUtc: now); + await AddApproval(OrderApprovalStatus.Pending, customerId: "another-buyer"); + await AddApproval(OrderApprovalStatus.Pending, 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); + 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 })); + } + + #endregion +} diff --git a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderServiceTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderServiceTests.cs index 38be5f574d..fe1f7555d4 100644 --- a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderServiceTests.cs +++ b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderServiceTests.cs @@ -18,6 +18,8 @@ public class OrderServiceTests private Mock _mediatorMock; private Mock> _orderNoteRepositoryMock; private Mock> _orderRepositoryMock; + private List _orderApprovals; + private Mock> _orderApprovalRepositoryMock; private OrderService _service; [TestInitialize] @@ -26,7 +28,11 @@ public void Init() _orderRepositoryMock = new Mock>(); _orderNoteRepositoryMock = new Mock>(); _mediatorMock = new Mock(); - _service = new OrderService(_orderRepositoryMock.Object, _orderNoteRepositoryMock.Object, _mediatorMock.Object); + _orderApprovals = []; + _orderApprovalRepositoryMock = new Mock>(); + _orderApprovalRepositoryMock.Setup(c => c.Table).Returns(() => _orderApprovals.AsQueryable()); + _service = new OrderService(_orderRepositoryMock.Object, _orderNoteRepositoryMock.Object, + _orderApprovalRepositoryMock.Object, _mediatorMock.Object); } [TestMethod] @@ -164,6 +170,62 @@ await Assert.ThrowsExactlyAsync(() => It.IsAny()), Times.Never); } + [TestMethod] + public async Task CancelExpiredOrders_OrderWithoutApproval_IsCancelled() + { + _orderRepositoryMock.Setup(c => c.Table).Returns(new List { NewExpiredOrder("1") }.AsQueryable()); + + await _service.CancelExpiredOrders(DateTime.UtcNow.AddDays(-7)); + + _mediatorMock.Verify(c => c.Send(It.Is(x => x.Order.Id == "1" && x.NotifyCustomer), + It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task CancelExpiredOrders_OrderApprovedAfterTheCutoff_IsSkipped() + { + _orderRepositoryMock.Setup(c => c.Table).Returns(new List { NewExpiredOrder("1") }.AsQueryable()); + _orderApprovals.Add(new OrderApproval { + OrderId = "1", Status = OrderApprovalStatus.Approved, DecidedOnUtc = DateTime.UtcNow.AddDays(-1) + }); + + await _service.CancelExpiredOrders(DateTime.UtcNow.AddDays(-7)); + + _mediatorMock.Verify(c => c.Send(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task CancelExpiredOrders_OrderApprovedBeforeTheCutoff_IsCancelled() + { + _orderRepositoryMock.Setup(c => c.Table).Returns(new List { NewExpiredOrder("1") }.AsQueryable()); + _orderApprovals.Add(new OrderApproval { + OrderId = "1", Status = OrderApprovalStatus.Approved, DecidedOnUtc = DateTime.UtcNow.AddDays(-8) + }); + + await _service.CancelExpiredOrders(DateTime.UtcNow.AddDays(-7)); + + _mediatorMock.Verify(c => c.Send(It.Is(x => x.Order.Id == "1"), + It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task CancelExpiredOrders_RecentApprovalOfAnotherOrder_DoesNotProtectThisOne() + { + _orderRepositoryMock.Setup(c => c.Table) + .Returns(new List { NewExpiredOrder("1"), NewExpiredOrder("2") }.AsQueryable()); + _orderApprovals.Add(new OrderApproval { + OrderId = "2", Status = OrderApprovalStatus.Approved, DecidedOnUtc = DateTime.UtcNow.AddDays(-1) + }); + + await _service.CancelExpiredOrders(DateTime.UtcNow.AddDays(-7)); + + _mediatorMock.Verify(c => c.Send(It.Is(x => x.Order.Id == "1"), + It.IsAny()), Times.Once); + _mediatorMock.Verify(c => c.Send(It.Is(x => x.Order.Id == "2"), + It.IsAny()), Times.Never); + } + private static Order NewExpiredOrder(string id) { return new Order { 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..8c97523054 100644 --- a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/ShoppingCartValidatorTests.cs +++ b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/ShoppingCartValidatorTests.cs @@ -75,6 +75,21 @@ public async Task GetShoppingCartWarnings_ValidatesForTheContextCustomerAndStore Assert.AreSame(Language, amount.Language); } + [TestMethod] + public async Task GetShoppingCartWarnings_ValidateAmountFalse_DoesNotRunTotalAmount() + { + //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()); + } + [TestMethod] public async Task GetRequiredProductWarnings_UsesTheGivenCustomerAndStore() { 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..a67f09c49c --- /dev/null +++ b/src/Tests/Grand.Business.Messages.Tests/Commands/GetOrderApprovalTokensCommandHandlerTests.cs @@ -0,0 +1,256 @@ +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Common.Localization; +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; +using Moq; + +namespace Grand.Business.Messages.Tests.Commands; + +[TestClass] +public class GetOrderApprovalTokensCommandHandlerTests +{ + private GetOrderApprovalTokensCommandHandler _handler; + private Dictionary _resources; + private Mock _translationService; + private Mock _orderService; + + [TestInitialize] + public void Init() + { + //resources the language has; a missing one gives the default, or an empty string when asked for that + _resources = new Dictionary(); + _translationService = new Mock(); + _translationService + .Setup(x => x.GetResource(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((string key, string _, string defaultValue, bool returnEmpty) => + _resources.TryGetValue(key, out var value) ? value : returnEmpty ? string.Empty : defaultValue); + _orderService = new Mock(); + _orderService.Setup(x => x.GetOrderById("order1")).ReturnsAsync(new Order { Id = "order1", OrderNumber = 1042 }); + _handler = new GetOrderApprovalTokensCommandHandler(_translationService.Object, _orderService.Object); + } + + private static OrderApproval Approval() + { + return new OrderApproval { + Id = "approval1", + OrderId = "order1", + 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 { Id = "lang1" } + }; + } + + [TestMethod] + public async Task Handle_WithoutAnOrderId_FallsBackToTheApprovalUrlAndLeavesTheNumberEmpty() + { + var approval = Approval(); + approval.OrderId = string.Empty; + + var tokens = await _handler.Handle(Command(approval), default); + + Assert.AreEqual(string.Empty, tokens.OrderNumber); + Assert.AreEqual("https://store.example/account/orderapproval?id=approval1", tokens.OrderUrl); + } + + [TestMethod] + public async Task Handle_KeepsTheStatusAsTheResourceText() + { + //the status is a translation, not user input, and the subject uses it as plain text: + //an encoded "Approuvé" would reach the subject line as "Approuvé" + _resources[$"Enums.{typeof(OrderApprovalStatus).FullName}.Pending"] = "Approuvé"; + + var tokens = await _handler.Handle(Command(Approval()), default); + + Assert.AreEqual("Approuvé", tokens.Status); + } + + [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/orderdetails/order1", result.OrderUrl); + } + + [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/orderdetails/order1", result.OrderUrl); + } + + [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); + } + + [TestMethod] + public async Task Handle_TranslatesTheStatusInTheEmailLanguage() + { + //Arrange + _resources["Enums.Grand.Domain.Orders.OrderApprovalStatus.Rejected"] = "Odrzucone"; + var approval = Approval(); + approval.Status = OrderApprovalStatus.Rejected; + + //Act + var result = await _handler.Handle(Command(approval), CancellationToken.None); + + //Assert + Assert.AreEqual("Odrzucone", result.Status); + _translationService.Verify(x => x.GetResource("Enums.Grand.Domain.Orders.OrderApprovalStatus.Rejected", + "lang1", It.IsAny(), It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task Handle_TranslatesTableHeadersAndSkuLabel() + { + //Arrange + _resources["OrderApproval.Email.Product"] = "Produkt"; + _resources["OrderApproval.Email.Quantity"] = "Ilosc"; + _resources["OrderApproval.Email.Price"] = "Cena"; + _resources["OrderApproval.Email.Total"] = "Razem"; + _resources["OrderApproval.Email.Sku"] = "Kod"; + var approval = Approval(); + approval.Items.Add(new OrderApprovalItem { ProductName = "Desk", Sku = "D-1", Quantity = 1 }); + + //Act + var result = await _handler.Handle(Command(approval), CancellationToken.None); + + //Assert + StringAssert.Contains(result.ItemsHtml, ">Produkt"); + StringAssert.Contains(result.ItemsHtml, ">Ilosc"); + StringAssert.Contains(result.ItemsHtml, ">Cena"); + StringAssert.Contains(result.ItemsHtml, ">Razem"); + StringAssert.Contains(result.ItemsHtml, "Kod: D-1"); + Assert.IsFalse(result.ItemsHtml.Contains("Quantity")); + Assert.IsFalse(result.ItemsHtml.Contains("SKU")); + } + + [TestMethod] + public async Task Handle_MissingResources_FallBackToEnglish() + { + //Arrange + var approval = Approval(); + approval.Items.Add(new OrderApprovalItem { ProductName = "Desk", Sku = "D-1", Quantity = 1 }); + + //Act + var result = await _handler.Handle(Command(approval), CancellationToken.None); + + //Assert + Assert.AreEqual("Pending", result.Status); + StringAssert.Contains(result.ItemsHtml, ">Product"); + StringAssert.Contains(result.ItemsHtml, ">Quantity"); + StringAssert.Contains(result.ItemsHtml, ">Price"); + StringAssert.Contains(result.ItemsHtml, ">Total"); + StringAssert.Contains(result.ItemsHtml, "SKU: D-1"); + } + + [TestMethod] + public async Task Handle_EncodesTranslatedLabels() + { + //Arrange - a translator's text must not break the table + _resources["OrderApproval.Email.Product"] = "Produkt & co"; + + //Act + var result = await _handler.Handle(Command(Approval()), CancellationToken.None); + + //Assert + StringAssert.Contains(result.ItemsHtml, "<i>Produkt</i> & co"); + Assert.IsFalse(result.ItemsHtml.Contains("")); + } + + [TestMethod] + public async Task Handle_FillsTheNumberOfTheOrderAwaitingTheDecision() + { + //Act + var result = await _handler.Handle(Command(Approval()), CancellationToken.None); + + //Assert + Assert.AreEqual("1042", result.OrderNumber); + } + + [TestMethod] + public async Task Handle_OrderGone_LeavesTheNumberEmptyAndLinksToTheRequest() + { + //Arrange + var approval = Approval(); + approval.OrderId = "deleted"; + + //Act + var result = await _handler.Handle(Command(approval), CancellationToken.None); + + //Assert + Assert.AreEqual(string.Empty, result.OrderNumber); + Assert.AreEqual("https://store.example/account/orderapproval?id=approval1", result.OrderUrl); + } +} 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/OrderApprovalResourceKeysTests.cs b/src/Tests/Grand.Modules.Tests/Installer/OrderApprovalResourceKeysTests.cs new file mode 100644 index 0000000000..ac801308ad --- /dev/null +++ b/src/Tests/Grand.Modules.Tests/Installer/OrderApprovalResourceKeysTests.cs @@ -0,0 +1,129 @@ +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Grand.Domain.Customers; +using Grand.Domain.Orders; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Modules.Tests.Installer; + +/// +/// The texts of order approval: every key the code and views ask for exists in the default language file +/// and in the upgrade file (existing installations get their texts from the latter), and neither file keeps a +/// key of this feature that nothing uses. Keys are compared in lower case, like the files store them. +/// +[TestClass] +public class OrderApprovalResourceKeysTests +{ + //the keys of this feature: by prefix, as the files keep them (lower case) + private static readonly Regex FeatureKey = new( + @"^(checkout\.orderapproval\.|orderapproval\.|account\.orderapprovals$" + + @"|account\.subaccounts\.fields\.(orderapprovallimit|role)(\.|$)" + + @"|admin\.customers\.customers\.subaccounts\.(orderapprovallimit|role)$" + + @"|admin\.configuration\.payment\.settings\.orderapprovalpaymentmethods$" + + @"|admin\.orders\.fields\.orderstatus\.change\.(to)?awaitingapproval$" + + @"|admin\.orders\.shipments\.orderawaitsapproval$" + + @"|enums\.grand\.domain\.orders\.orderapprovalstatus\." + + @"|enums\.grand\.domain\.customers\.subaccountrole\.)", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + //string literals in code and views; quoted, so a liquid token inside a longer text does not match + private static readonly Regex Literal = new(@"""([A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z0-9]+)+)""", RegexOptions.Compiled); + + //message template names look like resource keys but are not + private static readonly HashSet TemplateNames = + new(StringComparer.OrdinalIgnoreCase) { "OrderApproval.Requested", "OrderApproval.Decided" }; + + private static readonly string[] SkippedFolders = ["Tests", "bin", "obj", "node_modules", "App_Data", "wwwroot"]; + + private static string Resources => Path.Combine(RepositoryPaths.Root, "src", "Web", "Grand.Web", "App_Data", "Resources"); + + private static HashSet Referenced() + { + var keys = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var file in SourceFiles(Path.Combine(RepositoryPaths.Root, "src"))) + foreach (Match match in Literal.Matches(File.ReadAllText(file))) + { + var key = match.Groups[1].Value; + if (FeatureKey.IsMatch(key) && !TemplateNames.Contains(key)) keys.Add(key); + } + + //enum values are translated by name + foreach (var name in Enum.GetNames()) + keys.Add($"Enums.Grand.Domain.Orders.OrderApprovalStatus.{name}"); + foreach (var name in Enum.GetNames()) + keys.Add($"Enums.Grand.Domain.Customers.SubAccountRole.{name}"); + return keys; + } + + private static IEnumerable SourceFiles(string folder) + { + foreach (var file in Directory.EnumerateFiles(folder, "*.*", SearchOption.TopDirectoryOnly) + .Where(x => x.EndsWith(".cs") || x.EndsWith(".cshtml"))) + yield return file; + foreach (var directory in Directory.EnumerateDirectories(folder)) + { + if (SkippedFolders.Contains(Path.GetFileName(directory))) continue; + foreach (var file in SourceFiles(directory)) yield return file; + } + } + + //XDocument.Load reads the UTF-16 default language file as well as the UTF-8 upgrade file + private static HashSet Defined(string file) + { + return XDocument.Load(file).Root!.Elements("Resource") + .Select(x => x.Attribute("Name")!.Value) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + [TestMethod] + public void TheCodeReferencesTheKeysThisTestLooksFor() + { + //guards the guard: a broken scan would make the two tests below pass without checking anything + var referenced = Referenced(); + + Assert.IsTrue(referenced.Contains("Checkout.OrderApproval.WillBeSent")); + Assert.IsTrue(referenced.Contains("OrderApproval.Order")); + Assert.IsTrue(referenced.Contains("Admin.Configuration.Payment.Settings.OrderApprovalPaymentMethods")); + Assert.IsTrue(referenced.Contains("Enums.Grand.Domain.Orders.OrderApprovalStatus.Withdrawn")); + Assert.IsFalse(referenced.Contains("OrderApproval.Requested")); + } + + [TestMethod] + [DataRow("DefaultLanguage.xml")] + [DataRow("Upgrade/en_250.xml")] + public void EveryReferencedKeyIsDefined(string file) + { + var defined = Defined(Path.Combine(Resources, file)); + + var missing = Referenced().Where(x => !defined.Contains(x)).OrderBy(x => x).ToList(); + + Assert.AreEqual(0, missing.Count, $"{file} lacks: {string.Join(", ", missing)}"); + } + + [TestMethod] + [DataRow("DefaultLanguage.xml")] + [DataRow("Upgrade/en_250.xml")] + public void NoKeyOfTheFeatureIsLeftUnused(string file) + { + var referenced = Referenced(); + + var orphans = Defined(Path.Combine(Resources, file)) + .Where(x => FeatureKey.IsMatch(x) && !referenced.Contains(x)).OrderBy(x => x).ToList(); + + Assert.AreEqual(0, orphans.Count, $"{file} keeps unused: {string.Join(", ", orphans)}"); + } + + [TestMethod] + public void TheUpgradeFileHoldsTheSameTextsAsTheDefaultLanguage() + { + string Text(string file, string key) + { + return XDocument.Load(Path.Combine(Resources, file)).Root!.Elements("Resource") + .Single(x => string.Equals(x.Attribute("Name")!.Value, key, StringComparison.OrdinalIgnoreCase)) + .Element("Value")!.Value; + } + + foreach (var key in Referenced()) + Assert.AreEqual(Text("DefaultLanguage.xml", key), Text("Upgrade/en_250.xml", key), key); + } +} 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..1df7466c44 --- /dev/null +++ b/src/Tests/Grand.Modules.Tests/Installer/OrderApprovalTemplateSeedTests.cs @@ -0,0 +1,100 @@ +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 Requested_ShowsTheOrderNumber() + { + var body = LiquidExtensions.Render( + new LiquidObject { OrderApproval = new LiquidOrderApproval { OrderNumber = "1042" } }, + Template("OrderApproval.Requested").Body); + + StringAssert.Contains(body, "1042"); + } + + [TestMethod] + public void Decided_LinksToTheOrderNotTheCart() + { + var template = Template("OrderApproval.Decided"); + var body = LiquidExtensions.Render( + new LiquidObject { + OrderApproval = new LiquidOrderApproval { OrderUrl = "https://store.example/orderdetails/o1" } + }, template.Body); + + StringAssert.Contains(body, "href='https://store.example/orderdetails/o1'"); + Assert.IsFalse(body.Contains("/cart")); + Assert.IsFalse(template.Body.Contains("OrderApproval.CartUrl")); + } + + [TestMethod] + [DataRow("OrderApproval.Requested")] + [DataRow("OrderApproval.Decided")] + public void Subject_HasNoEmDash(string name) + { + Assert.IsFalse(Template(name).Subject.Contains('—')); + } + + [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")); + } +} diff --git a/src/Tests/Grand.Modules.Tests/Installer/OrderStatusSeedTests.cs b/src/Tests/Grand.Modules.Tests/Installer/OrderStatusSeedTests.cs new file mode 100644 index 0000000000..b288ee3889 --- /dev/null +++ b/src/Tests/Grand.Modules.Tests/Installer/OrderStatusSeedTests.cs @@ -0,0 +1,37 @@ +using System.Text.RegularExpressions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Modules.Tests.Installer; + +/// +/// New installations get the status that orders awaiting approval use and the default list of payment +/// methods allowed for them. Read from the installer source, like the other seed guards in this folder. +/// +[TestClass] +public class OrderStatusSeedTests +{ + [TestMethod] + public void InstallOrderStatus_SeedsTheAwaitingApprovalSystemStatus() + { + var source = File.ReadAllText(Path.Combine(RepositoryPaths.InstallerServices, "InstallDataOrderStatus.cs")); + var seed = Regex.Match(source, + @"new\(\)\s*\{(?[^}]*StatusId\s*=\s*5\b[^}]*)\}", RegexOptions.Singleline); + + Assert.IsTrue(seed.Success, "status 5 not seeded in InstallDataOrderStatus.cs"); + var body = seed.Groups["body"].Value; + StringAssert.Matches(body, new Regex(@"IsSystem\s*=\s*true")); + StringAssert.Matches(body, new Regex(@"Name\s*=\s*""Awaiting approval""")); + StringAssert.Matches(body, new Regex(@"DisplayOrder\s*=\s*5\b")); + } + + [TestMethod] + public void InstallSettings_SeedsCashOnDeliveryForOrdersAwaitingApproval() + { + var source = File.ReadAllText(Path.Combine(RepositoryPaths.InstallerServices, "InstallDataSettings.cs")); + var seed = Regex.Match(source, @"new OrderApprovalSettings\s*\{(?.*?)\}\);", RegexOptions.Singleline); + + Assert.IsTrue(seed.Success, "OrderApprovalSettings seed not found in InstallDataSettings.cs"); + StringAssert.Matches(seed.Groups["body"].Value, + new Regex(@"PaymentMethodSystemNames\s*=\s*\[\s*""Payments\.CashOnDelivery""\s*\]")); + } +} diff --git a/src/Tests/Grand.Modules.Tests/Services/Migrations/MigrationOrderApprovalTests.cs b/src/Tests/Grand.Modules.Tests/Services/Migrations/MigrationOrderApprovalTests.cs new file mode 100644 index 0000000000..7dcc89ba0a --- /dev/null +++ b/src/Tests/Grand.Modules.Tests/Services/Migrations/MigrationOrderApprovalTests.cs @@ -0,0 +1,295 @@ +using Grand.Data; +using Grand.Data.Tests.LiteDb; +using Grand.Domain.Configuration; +using Grand.Domain.Messages; +using Grand.Domain.Orders; +using Grand.Infrastructure.Caching; +using Grand.Infrastructure.Caching.Constants; +using Grand.Module.Installer.Services.MessageTemplates; +using Grand.Module.Migration.Migrations._2._5; +using Grand.Modules.Tests.Installer; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Text.RegularExpressions; + +namespace Grand.Modules.Tests.Services.Migrations; + +[TestClass] +public class MigrationOrderApprovalTests +{ + private static readonly string[] TemplateNames = ["OrderApproval.Requested", "OrderApproval.Decided"]; + + private LiteDBRepositoryMock _templates; + private LiteDBRepositoryMock _emailAccounts; + private LiteDBRepositoryMock _settings; + private LiteDBRepositoryMock _orderStatuses; + private Mock _context; + private Mock _cache; + private IServiceProvider _serviceProvider; + private MigrationOrderApproval _migration; + + [TestInitialize] + public void Init() + { + _templates = new LiteDBRepositoryMock(); + _emailAccounts = new LiteDBRepositoryMock(); + _settings = new LiteDBRepositoryMock(); + _orderStatuses = new LiteDBRepositoryMock(); + _context = new Mock(); + _cache = new Mock(); + _serviceProvider = BuildServices(); + _migration = new MigrationOrderApproval(); + } + + private IServiceProvider BuildServices() + { + var services = new ServiceCollection(); + services.AddSingleton>(_templates); + services.AddSingleton>(_emailAccounts); + services.AddSingleton>(_settings); + services.AddSingleton>(_orderStatuses); + services.AddSingleton(Mock.Of>()); + services.AddSingleton(_context.Object); + services.AddSingleton(_cache.Object); + services.AddSingleton>(NullLogger.Instance); + return services.BuildServiceProvider(); + } + + private void SetDefaultEmailAccount(string id) + { + _settings.Insert(SettingExtensions.CreateSetting(new EmailAccountSettings { DefaultEmailAccountId = id })); + } + + [TestMethod] + public async Task Upgrade_CreatesTemplatesOnce() + { + var account = new EmailAccount { Email = "first@example.com" }; + _emailAccounts.Insert(account); + + Assert.IsTrue(await _migration.UpgradeProcess(_serviceProvider)); + + var templates = _templates.Table.ToList(); + CollectionAssert.AreEquivalent(TemplateNames, templates.Select(x => x.Name).ToList()); + foreach (var template in templates) + { + Assert.IsTrue(template.IsActive); + Assert.AreEqual(account.Id, template.EmailAccountId); + Assert.IsFalse(string.IsNullOrWhiteSpace(template.Subject)); + Assert.IsFalse(string.IsNullOrWhiteSpace(template.Body)); + } + + _cache.Verify(x => x.RemoveByPrefix(CacheKey.MESSAGETEMPLATES_PATTERN_KEY, It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task Upgrade_SecondRun_ChangesNothing() + { + _emailAccounts.Insert(new EmailAccount { Email = "first@example.com" }); + Assert.IsTrue(await _migration.UpgradeProcess(_serviceProvider)); + var before = _templates.Table.OrderBy(x => x.Name).ToList().Select(x => (x.Id, x.Name, x.Subject, x.Body)).ToList(); + + Assert.IsTrue(await _migration.UpgradeProcess(_serviceProvider)); + + var after = _templates.Table.OrderBy(x => x.Name).ToList().Select(x => (x.Id, x.Name, x.Subject, x.Body)).ToList(); + CollectionAssert.AreEqual(before, after); + //nothing inserted, so no reason to drop the cached lookups again + _cache.Verify(x => x.RemoveByPrefix(CacheKey.MESSAGETEMPLATES_PATTERN_KEY, It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task Upgrade_ExistingTemplateKept() + { + _templates.Insert(new MessageTemplate { + Name = "OrderApproval.Requested", Subject = "Our subject", Body = "

Our text

", IsActive = false + }); + + Assert.IsTrue(await _migration.UpgradeProcess(_serviceProvider)); + + var kept = _templates.Table.Single(x => x.Name == "OrderApproval.Requested"); + Assert.AreEqual("Our subject", kept.Subject); + Assert.AreEqual("

Our text

", kept.Body); + Assert.IsFalse(kept.IsActive); + //the one the operator did not write is still added + Assert.AreEqual(1, _templates.Table.Count(x => x.Name == "OrderApproval.Decided")); + Assert.AreEqual(2, _templates.Table.Count()); + } + + [TestMethod] + public async Task Upgrade_DefaultEmailAccountSetting_WinsOverTheFirstAccount() + { + _emailAccounts.Insert(new EmailAccount { Email = "first@example.com" }); + var defaultAccount = new EmailAccount { Email = "default@example.com" }; + _emailAccounts.Insert(defaultAccount); + SetDefaultEmailAccount(defaultAccount.Id); + + Assert.IsTrue(await _migration.UpgradeProcess(_serviceProvider)); + + Assert.IsTrue(_templates.Table.All(x => x.EmailAccountId == defaultAccount.Id)); + } + + [TestMethod] + public async Task Upgrade_DefaultEmailAccountGone_UsesTheFirstAccount() + { + var first = new EmailAccount { Email = "first@example.com" }; + _emailAccounts.Insert(first); + SetDefaultEmailAccount("deleted-account"); + + Assert.IsTrue(await _migration.UpgradeProcess(_serviceProvider)); + + Assert.IsTrue(_templates.Table.All(x => x.EmailAccountId == first.Id)); + } + + [TestMethod] + public async Task Upgrade_NoEmailAccount_LeavesItEmptySoTheDefaultApplies() + { + Assert.IsTrue(await _migration.UpgradeProcess(_serviceProvider)); + + Assert.AreEqual(2, _templates.Table.Count()); + Assert.IsTrue(_templates.Table.All(x => string.IsNullOrEmpty(x.EmailAccountId))); + } + + [TestMethod] + public async Task Upgrade_CreatesTheOrderApprovalIndexesTheInstallerCreates() + { + Assert.IsTrue(await _migration.UpgradeProcess(_serviceProvider)); + + _context.Verify(x => x.CreateIndex(It.IsAny>(), + It.IsAny>(), "CompanyOwnerId_Status_CreatedOnUtc", false), Times.Once); + _context.Verify(x => x.CreateIndex(It.IsAny>(), + It.IsAny>(), "CustomerId_StoreId_Status", false), Times.Once); + //read on every placement awaiting approval and on every cancel; not unique + _context.Verify(x => x.CreateIndex(It.IsAny>(), + It.IsAny>(), "OrderId", false), Times.Once); + } + + [TestMethod] + public async Task Upgrade_IndexFails_ReturnsFalseWithoutThrowing() + { + _context.Setup(x => x.CreateIndex(It.IsAny>(), + It.IsAny>(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("index")); + + Assert.IsFalse(await _migration.UpgradeProcess(_serviceProvider)); + } + + [TestMethod] + public async Task Upgrade_SeedsTheAwaitingApprovalStatusOnce() + { + Assert.IsTrue(await _migration.UpgradeProcess(_serviceProvider)); + Assert.IsTrue(await _migration.UpgradeProcess(_serviceProvider)); + + var status = _orderStatuses.Table.Single(x => x.StatusId == (int)OrderStatusSystem.AwaitingApproval); + Assert.AreEqual("Awaiting approval", status.Name); + Assert.AreEqual(5, status.DisplayOrder); + Assert.IsTrue(status.IsSystem); + Assert.AreEqual(1, _orderStatuses.Table.Count()); + //the cached list of statuses would not show the new one + _cache.Verify(x => x.RemoveByPrefix(CacheKey.ORDER_STATUS_ALL, It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task Upgrade_ExistingStatusWithThatIdIsKept() + { + _orderStatuses.Insert(new OrderStatus { StatusId = 5, Name = "Our status", DisplayOrder = 9, IsSystem = false }); + + Assert.IsTrue(await _migration.UpgradeProcess(_serviceProvider)); + + var status = _orderStatuses.Table.Single(); + Assert.AreEqual("Our status", status.Name); + Assert.AreEqual(9, status.DisplayOrder); + Assert.IsFalse(status.IsSystem); + } + + [TestMethod] + public async Task Upgrade_LeavesTheOtherOrderStatusesAlone() + { + _orderStatuses.Insert(new OrderStatus { StatusId = 10, Name = "Pending", IsSystem = true }); + + Assert.IsTrue(await _migration.UpgradeProcess(_serviceProvider)); + + Assert.AreEqual(2, _orderStatuses.Table.Count()); + Assert.AreEqual("Pending", _orderStatuses.Table.Single(x => x.StatusId == 10).Name); + } + + [TestMethod] + public async Task Upgrade_SeedsTheOrderApprovalSettingsOnce() + { + Assert.IsTrue(await _migration.UpgradeProcess(_serviceProvider)); + Assert.IsTrue(await _migration.UpgradeProcess(_serviceProvider)); + + var name = nameof(OrderApprovalSettings).ToLowerInvariant(); + var setting = _settings.Table.Single(x => x.Name == name); + Assert.IsTrue(string.IsNullOrEmpty(setting.StoreId)); + var settings = System.Text.Json.JsonSerializer.Deserialize(setting.Metadata); + CollectionAssert.AreEqual(new[] { "Payments.CashOnDelivery" }, settings.PaymentMethodSystemNames); + } + + [TestMethod] + public async Task Upgrade_ExistingOrderApprovalSettingsAreKept() + { + _settings.Insert(SettingExtensions.CreateSetting(new OrderApprovalSettings { + PaymentMethodSystemNames = ["Payments.Invoice", "Payments.BankTransfer"] + })); + + Assert.IsTrue(await _migration.UpgradeProcess(_serviceProvider)); + + var name = nameof(OrderApprovalSettings).ToLowerInvariant(); + var setting = _settings.Table.Single(x => x.Name == name); + var settings = System.Text.Json.JsonSerializer.Deserialize(setting.Metadata); + CollectionAssert.AreEqual(new[] { "Payments.Invoice", "Payments.BankTransfer" }, + settings.PaymentMethodSystemNames); + } + + [TestMethod] + public void Templates_RequestedShowsTheOrderNumber_DecidedLinksToTheOrder() + { + var requested = MigrationOrderApproval.Templates("ea").Single(x => x.Name == "OrderApproval.Requested"); + var decided = MigrationOrderApproval.Templates("ea").Single(x => x.Name == "OrderApproval.Decided"); + + StringAssert.Contains(requested.Body, "{{OrderApproval.OrderNumber}}"); + StringAssert.Contains(decided.Body, "{{OrderApproval.OrderUrl}}"); + Assert.IsFalse(decided.Body.Contains("OrderApproval.CartUrl")); + Assert.IsFalse(requested.Subject.Contains('—') || decided.Subject.Contains('—')); + } + + [TestMethod] + [DataRow("OrderApproval.Requested")] + [DataRow("OrderApproval.Decided")] + public void Templates_UseTheSubjectAndTokensOfTheInstallerSeed(string name) + { + var seeded = MessageTemplateSeed.Build("ea").Single(x => x.Name == name); + var migrated = MigrationOrderApproval.Templates("ea").Single(x => x.Name == name); + + Assert.AreEqual(seeded.Subject, migrated.Subject); + Assert.IsTrue(migrated.IsActive); + CollectionAssert.AreEqual( + MessageTemplateSeedTests.Tokens(seeded.Body).ToList(), + MessageTemplateSeedTests.Tokens(migrated.Body).ToList()); + // the markup too, whatever the indentation + Assert.AreEqual(Normalize(seeded.Body), Normalize(migrated.Body)); + } + + private static string Normalize(string body) + { + return Regex.Replace(body, @"\s+", " "); + } + + [TestMethod] + [DataRow("OrderApproval.Requested")] + [DataRow("OrderApproval.Decided")] + public void Templates_DoNotRenderABareOrderSignWithoutAnOrderNumber(string name) + { + var body = MigrationOrderApproval.Templates("ea").Single(x => x.Name == name).Body; + var seeded = MessageTemplateSeed.Build("ea").Single(x => x.Name == name).Body; + + foreach (var text in new[] { body, seeded }) + { + var unguarded = Regex.Replace(text, @"\{% if OrderApproval\.OrderNumber != '' %\}.*?\{% (else|endif) %\}", "", + RegexOptions.Singleline); + Assert.IsFalse(unguarded.Contains("#{{OrderApproval.OrderNumber}}")); + } + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseOrderManagementControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseOrderManagementControllerTests.cs index e7cf46e8f6..723ed60c8d 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseOrderManagementControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseOrderManagementControllerTests.cs @@ -53,6 +53,8 @@ private class TestOrderManagementController( private Mock _orderAddressViewModelServiceMock; private Mock _mediatorMock; private Mock> _scopeMock; + private Mock _orderStatusServiceMock; + private Mock _translationServiceMock; [TestInitialize] public void Setup() @@ -64,12 +66,15 @@ public void Setup() _mediatorMock = new Mock(); _scopeMock = new Mock>(); - var translationServiceMock = new Mock(); - translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); + _translationServiceMock = new Mock(); + _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); + _orderStatusServiceMock = new Mock(); + _orderStatusServiceMock.Setup(s => s.GetByStatusId(It.IsAny())) + .ReturnsAsync((int statusId) => new OrderStatus { StatusId = statusId, Name = "status" }); _controller = new TestOrderManagementController( new Mock().Object, _orderServiceMock.Object, - new Mock().Object, translationServiceMock.Object, + _orderStatusServiceMock.Object, _translationServiceMock.Object, new Mock().Object, new Mock().Object, _mediatorMock.Object, _scopeMock.Object, _orderNoteViewModelServiceMock.Object, _orderAddressViewModelServiceMock.Object, _orderProductViewModelServiceMock.Object); @@ -144,6 +149,56 @@ public async Task ChangeOrderStatus_ScopeDenies_RedirectsToList_NoUpdate() _orderServiceMock.Verify(s => s.UpdateOrder(It.IsAny()), Times.Never); } + [TestMethod] + public async Task ChangeOrderStatus_OrderAwaitingApproval_RefusesWithoutUpdate() + { + var order = new Order { Id = "o1", OrderStatusId = (int)OrderStatusSystem.AwaitingApproval }; + _orderServiceMock.Setup(s => s.GetOrderById("o1")).ReturnsAsync(order); + _scopeMock.Setup(s => s.HasAccess(order)).ReturnsAsync(true); + + var result = await _controller.ChangeOrderStatus("o1", + new OrderModel { OrderStatusId = (int)OrderStatusSystem.Processing }); + + Assert.AreEqual("Edit", (result as RedirectToActionResult)?.ActionName); + Assert.AreEqual((int)OrderStatusSystem.AwaitingApproval, order.OrderStatusId); + _translationServiceMock.Verify( + t => t.GetResource("Admin.Orders.Fields.OrderStatus.Change.AwaitingApproval"), Times.Once); + _orderServiceMock.Verify(s => s.UpdateOrder(It.IsAny()), Times.Never); + _orderServiceMock.Verify(s => s.InsertOrderNote(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ChangeOrderStatus_ToAwaitingApproval_RefusesWithoutUpdate() + { + var order = new Order { Id = "o1", OrderStatusId = (int)OrderStatusSystem.Pending }; + _orderServiceMock.Setup(s => s.GetOrderById("o1")).ReturnsAsync(order); + _scopeMock.Setup(s => s.HasAccess(order)).ReturnsAsync(true); + + var result = await _controller.ChangeOrderStatus("o1", + new OrderModel { OrderStatusId = (int)OrderStatusSystem.AwaitingApproval }); + + Assert.AreEqual("Edit", (result as RedirectToActionResult)?.ActionName); + Assert.AreEqual((int)OrderStatusSystem.Pending, order.OrderStatusId); + _translationServiceMock.Verify( + t => t.GetResource("Admin.Orders.Fields.OrderStatus.Change.ToAwaitingApproval"), Times.Once); + _orderServiceMock.Verify(s => s.UpdateOrder(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ChangeOrderStatus_OrdinaryChange_UpdatesOrder() + { + var order = new Order { Id = "o1", OrderStatusId = (int)OrderStatusSystem.Pending }; + _orderServiceMock.Setup(s => s.GetOrderById("o1")).ReturnsAsync(order); + _scopeMock.Setup(s => s.HasAccess(order)).ReturnsAsync(true); + + var result = await _controller.ChangeOrderStatus("o1", + new OrderModel { OrderStatusId = (int)OrderStatusSystem.Processing }); + + Assert.AreEqual("Edit", (result as RedirectToActionResult)?.ActionName); + Assert.AreEqual((int)OrderStatusSystem.Processing, order.OrderStatusId); + _orderServiceMock.Verify(s => s.UpdateOrder(order), Times.Once); + } + [TestMethod] public async Task Delete_ScopeDenies_RedirectsToList_NoDeleteCommand() { diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseShipmentControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseShipmentControllerTests.cs index 504e55a0cc..eda3e72e07 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseShipmentControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseShipmentControllerTests.cs @@ -54,6 +54,7 @@ private class TestShipmentController( private Mock> _orderScopeMock; private Mock _mediatorMock; private Mock _pdfServiceMock; + private Mock _translationServiceMock; [TestInitialize] public void Setup() @@ -67,8 +68,9 @@ public void Setup() _orderScopeMock = new Mock>(); _orderScopeMock.Setup(s => s.HasAccess(It.IsAny())).ReturnsAsync(true); - var translationServiceMock = new Mock(); - translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); + _translationServiceMock = new Mock(); + _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); + var translationServiceMock = _translationServiceMock; var contextAccessorMock = new Mock(); var workContextMock = new Mock(); workContextMock.Setup(w => w.WorkingLanguage).Returns(new Language { Id = "lang-1" }); @@ -233,6 +235,47 @@ public async Task AddShipmentGet_OrderDenied_RedirectsToList() _shipmentViewModelServiceMock.Verify(v => v.PrepareShipmentModel(It.IsAny()), Times.Never); } + [TestMethod] + public async Task AddShipmentGet_OrderAwaitingApproval_RefusesAndRedirectsToOrder() + { + var order = new Order { Id = "o1", OrderStatusId = (int)OrderStatusSystem.AwaitingApproval }; + _orderServiceMock.Setup(s => s.GetOrderById("o1")).ReturnsAsync(order); + + var result = await _controller.AddShipment("o1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("Edit", redirect.ActionName); + Assert.AreEqual("Order", redirect.ControllerName); + Assert.AreEqual("o1", redirect.RouteValues["id"]); + _translationServiceMock.Verify(t => t.GetResource("Admin.Orders.Shipments.OrderAwaitsApproval"), Times.Once); + _shipmentViewModelServiceMock.Verify(v => v.PrepareShipmentModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AddShipmentPost_OrderAwaitingApproval_RefusesWithoutShipment() + { + var order = new Order { Id = "o1", OrderStatusId = (int)OrderStatusSystem.AwaitingApproval }; + _orderServiceMock.Setup(s => s.GetOrderById("o1")).ReturnsAsync(order); + _scopeMock.Setup(s => s.FilterOrderItems(It.IsAny>())) + .Returns((IEnumerable items) => items); + var shipment = new Shipment { Id = "s1", ShipmentItems = { new ShipmentItem() } }; + _shipmentViewModelServiceMock + .Setup(v => v.PrepareShipment(order, It.IsAny>(), It.IsAny())) + .ReturnsAsync((shipment, (double?)1)); + _shipmentViewModelServiceMock.Setup(v => v.ValidStockShipment(shipment)).ReturnsAsync((true, null)); + + var result = await _controller.AddShipment(new AddShipmentModel { OrderId = "o1" }, false); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("Edit", redirect.ActionName); + Assert.AreEqual("Order", redirect.ControllerName); + _translationServiceMock.Verify(t => t.GetResource("Admin.Orders.Shipments.OrderAwaitsApproval"), Times.Once); + _shipmentServiceMock.Verify(s => s.InsertShipment(It.IsAny()), Times.Never); + _orderServiceMock.Verify(s => s.InsertOrderNote(It.IsAny()), Times.Never); + } + [TestMethod] public async Task AddShipmentPost_NoItemsSelected_ShowsErrorAndRedirects() { diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/PaymentControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/PaymentControllerTests.cs index ca836c3fd2..36a5f70b46 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/PaymentControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/PaymentControllerTests.cs @@ -3,6 +3,8 @@ using Grand.Business.Core.Interfaces.Common.Configuration; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Domain.Directory; +using Grand.Domain.Orders; +using Grand.Domain.Payments; using Grand.Domain.Shipping; using Grand.Infrastructure; using Grand.Infrastructure.Mapper; @@ -41,6 +43,7 @@ public void Setup() { cfg.AddProfile(); cfg.AddProfile(); + cfg.AddProfile(); }); AutoMapperConfig.Init(mapperConfig); @@ -162,4 +165,77 @@ public async Task MethodRestrictionsSave_NoFormValues_SaveEmptyRestrictionsWithA _paymentServiceMock.Verify(p => p.SaveRestrictedShippingIds(provider.Object, It.Is>(x => x.Count == 0), StoreId), Times.Once); } + + [TestMethod] + public async Task Settings_Get_OffersActiveMethodsAndSelectsTheConfiguredOnes() + { + var cod = CreatePaymentProvider("Payments.CashOnDelivery"); + var card = CreatePaymentProvider("Payments.Card"); + var inactive = CreatePaymentProvider("Payments.Inactive"); + _paymentServiceMock.Setup(p => p.LoadAllPaymentMethods(null, "", "")) + .ReturnsAsync(new List { cod.Object, card.Object, inactive.Object }); + _settingServiceMock.Setup(s => s.LoadSetting(StoreId)).ReturnsAsync(new PaymentSettings { + ActivePaymentProviderSystemNames = ["Payments.CashOnDelivery", "Payments.Card"] + }); + _settingServiceMock.Setup(s => s.LoadSetting("")).ReturnsAsync( + new OrderApprovalSettings { PaymentMethodSystemNames = ["Payments.CashOnDelivery"] }); + + var result = await _controller.Settings(); + + var model = (PaymentSettingsModel)((ViewResult)result).Model; + CollectionAssert.AreEquivalent(new[] { "Payments.CashOnDelivery", "Payments.Card" }, + model.AvailableOrderApprovalPaymentMethods.Select(x => x.Value).ToArray()); + CollectionAssert.AreEqual(new[] { "Payments.CashOnDelivery" }, + model.OrderApprovalPaymentMethodSystemNames.ToArray()); + Assert.IsTrue(model.AvailableOrderApprovalPaymentMethods.First().Text.Contains("Test method")); + } + + [TestMethod] + public async Task Settings_Get_KeepsAConfiguredMethodThatIsNoLongerActive() + { + _paymentServiceMock.Setup(p => p.LoadAllPaymentMethods(null, "", "")) + .ReturnsAsync(new List()); + _settingServiceMock.Setup(s => s.LoadSetting(StoreId)) + .ReturnsAsync(new PaymentSettings()); + _settingServiceMock.Setup(s => s.LoadSetting("")).ReturnsAsync( + new OrderApprovalSettings { PaymentMethodSystemNames = ["Payments.Gone"] }); + + var model = (PaymentSettingsModel)((ViewResult)await _controller.Settings()).Model; + + Assert.AreEqual("Payments.Gone", model.AvailableOrderApprovalPaymentMethods.Single().Value); + } + + [TestMethod] + public async Task SettingsSave_SavesOrderApprovalMethodsGloballyAndPaymentSettingsInTheStoreScope() + { + var paymentSettings = new PaymentSettings(); + var approvalSettings = new OrderApprovalSettings { PaymentMethodSystemNames = ["Payments.Old"] }; + _settingServiceMock.Setup(s => s.LoadSetting(StoreId)).ReturnsAsync(paymentSettings); + _settingServiceMock.Setup(s => s.LoadSetting("")).ReturnsAsync(approvalSettings); + + var result = await _controller.Settings(new PaymentSettingsModel { + SkipPaymentInfo = true, + OrderApprovalPaymentMethodSystemNames = ["Payments.CashOnDelivery", "Payments.Invoice"] + }); + + Assert.IsInstanceOfType(result); + Assert.IsTrue(paymentSettings.SkipPaymentInfo); + CollectionAssert.AreEqual(new[] { "Payments.CashOnDelivery", "Payments.Invoice" }, + approvalSettings.PaymentMethodSystemNames.ToArray()); + _settingServiceMock.Verify(s => s.SaveSetting(approvalSettings, ""), Times.Once); + _settingServiceMock.Verify(s => s.SaveSetting(paymentSettings, StoreId), Times.Once); + } + + [TestMethod] + public async Task SettingsSave_NothingSelected_SavesAnEmptyList() + { + var approvalSettings = new OrderApprovalSettings { PaymentMethodSystemNames = ["Payments.Old"] }; + _settingServiceMock.Setup(s => s.LoadSetting(StoreId)).ReturnsAsync(new PaymentSettings()); + _settingServiceMock.Setup(s => s.LoadSetting("")).ReturnsAsync(approvalSettings); + + await _controller.Settings(new PaymentSettingsModel()); + + Assert.AreEqual(0, approvalSettings.PaymentMethodSystemNames.Count); + _settingServiceMock.Verify(s => s.SaveSetting(approvalSettings, ""), Times.Once); + } } diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs index 9cf0c5282b..136630c15b 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs @@ -1,4 +1,5 @@ using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Catalog.Prices; using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Checkout.Orders; using Grand.Business.Core.Interfaces.Common.Addresses; @@ -50,6 +51,8 @@ public class CustomerViewModelServiceTests private Customer _currentCustomer; private CustomerConfig _customerConfig; private Mock> _scopeMock; + private Mock _priceFormatterMock; + private Mock _currencyServiceMock; private CustomerViewModelService _customerViewModelService; [TestInitialize] @@ -125,6 +128,11 @@ public void Setup() _scopeMock.Setup(s => s.GetDefaultStoreIdAsync()).ReturnsAsync((string)null); _scopeMock.Setup(s => s.HasAccess(It.IsAny())).ReturnsAsync(true); + _priceFormatterMock = new Mock(); + _currencyServiceMock = new Mock(); + _currencyServiceMock.Setup(c => c.GetPrimaryStoreCurrency()) + .ReturnsAsync(new Grand.Domain.Directory.Currency { CurrencyCode = "EUR" }); + _customerViewModelService = new CustomerViewModelService( _customerServiceMock.Object, _groupServiceMock.Object, @@ -146,7 +154,9 @@ public void Setup() new CommonSettings(), _enumTranslationServiceMock.Object, _customerConfig, - _scopeMock.Object); + _scopeMock.Object, + _priceFormatterMock.Object, + _currencyServiceMock.Object); } [TestMethod] @@ -579,6 +589,31 @@ public async Task PrepareCustomerModel_Owner_ListsItsSubAccounts() Assert.IsTrue(string.IsNullOrEmpty(model.OwnerCustomerId)); } + [TestMethod] + public async Task PrepareCustomerModel_SubAccountsShowRoleAndLimit() + { + _enumTranslationServiceMock.Setup(e => e.GetTranslationEnum(SubAccountRole.Buyer)).Returns("Buyer text"); + _enumTranslationServiceMock.Setup(e => e.GetTranslationEnum(SubAccountRole.Approver)).Returns("Approver text"); + _priceFormatterMock.Setup(p => p.FormatPrice(1200, It.Is(c => c.CurrencyCode == "EUR"))) + .Returns("EUR 1,200.00"); + SetupGetAllCustomers(new PagedList { + new() { Id = "sub-1", Email = "a@firm.com", OwnerId = "owner-1", SubAccountRole = SubAccountRole.Buyer, OrderApprovalLimit = 1200 }, + new() { Id = "sub-2", Email = "b@firm.com", OwnerId = "owner-1", SubAccountRole = SubAccountRole.Buyer }, + new() { Id = "sub-3", Email = "c@firm.com", OwnerId = "owner-1", SubAccountRole = SubAccountRole.Approver } + }); + + var model = new CustomerModel(); + await _customerViewModelService.PrepareCustomerModel(model, new Customer { Id = "owner-1" }, false); + + Assert.AreEqual("Buyer text", model.SubAccounts[0].Role); + Assert.AreEqual("EUR 1,200.00", model.SubAccounts[0].OrderApprovalLimit); + //no limit is shown as empty, not as a zero price + Assert.AreEqual("Buyer text", model.SubAccounts[1].Role); + Assert.IsTrue(string.IsNullOrEmpty(model.SubAccounts[1].OrderApprovalLimit)); + Assert.AreEqual("Approver text", model.SubAccounts[2].Role); + Assert.IsTrue(string.IsNullOrEmpty(model.SubAccounts[2].OrderApprovalLimit)); + } + [TestMethod] public async Task PrepareCustomerModel_StorePanel_QueriesOnlyItsStoreAndSkipsOutOfScopeRows() { diff --git a/src/Tests/Grand.Web.Tests/Commands/Customers/SubAccountEditCommandHandlerTests.cs b/src/Tests/Grand.Web.Tests/Commands/Customers/SubAccountEditCommandHandlerTests.cs index aa09ae098c..f5156f3bd4 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.Globalization; using System.Linq.Expressions; namespace Grand.Web.Tests.Commands.Customers; @@ -23,11 +24,18 @@ public void Init() { _customerServiceMock = new Mock(); _customerManagerServiceMock = new Mock(); + _culture = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = new CultureInfo("en-US"); _customerSettings = new CustomerSettings { AllowUsersToChangeEmail = true }; _handler = new SubAccountEditCommandHandler(_customerServiceMock.Object, _customerManagerServiceMock.Object, _customerSettings); } + private CultureInfo _culture; + + [TestCleanup] + public void Restore() => CultureInfo.CurrentCulture = _culture; + private Customer SubAccount(string ownerId = "owner") { var customer = new Customer { @@ -37,10 +45,12 @@ private Customer SubAccount(string ownerId = "owner") return customer; } - private static SubAccountEditCommand Command(string email = "sub@x.com", string password = null) => new() { + private static SubAccountEditCommand Command(string email = "sub@x.com", string password = null, + SubAccountRole role = SubAccountRole.Buyer, string limit = null) => new() { CurrentCustomer = new Customer { Id = "owner" }, EditModel = new SubAccountEditModel { - Id = "c1", Email = email, Password = password, Active = true, FirstName = "John", LastName = "Doe" + Id = "c1", Email = email, Password = password, Active = true, FirstName = "John", LastName = "Doe", + Role = role, OrderApprovalLimit = limit } }; @@ -53,6 +63,69 @@ private void VerifyNothingWritten() It.IsAny(), It.IsAny()), Times.Never); _customerManagerServiceMock.Verify(m => m.ChangePassword(It.IsAny(), It.IsAny()), Times.Never); + _customerServiceMock.Verify(c => c.UpdateCustomerField(It.IsAny(), + It.IsAny>>(), It.IsAny()), Times.Never); + _customerServiceMock.Verify(c => c.UpdateCustomerField(It.IsAny(), + It.IsAny>>(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Handle_SetsRoleAndLimit() + { + var customer = SubAccount(); + + var result = await _handler.Handle(Command(role: SubAccountRole.Buyer, limit: "250.5"), default); + + Assert.IsTrue(result); + Assert.AreEqual(SubAccountRole.Buyer, customer.SubAccountRole); + Assert.AreEqual(250.5, customer.OrderApprovalLimit); + _customerServiceMock.Verify(c => c.UpdateCustomerField(customer, + It.IsAny>>(), SubAccountRole.Buyer), Times.Once); + _customerServiceMock.Verify(c => c.UpdateCustomerField(customer, + It.IsAny>>(), (double?)250.5), Times.Once); + } + + [TestMethod] + [DataRow("pl-PL", "250,5", 250.5)] + [DataRow("de-DE", "1.500,50", 1500.5)] + [DataRow("en-US", "250.5", 250.5)] + [DataRow("pl-PL", "", null)] + [DataRow("pl-PL", " ", null)] + public async Task Handle_ReadsTheLimitInTheCurrentCulture(string culture, string typed, double? expected) + { + CultureInfo.CurrentCulture = new CultureInfo(culture); + var customer = SubAccount(); + + await _handler.Handle(Command(limit: typed), default); + + Assert.AreEqual(expected, customer.OrderApprovalLimit); + } + + [TestMethod] + public async Task Handle_BuyerWithoutLimit_ClearsAPreviousLimit() + { + var customer = SubAccount(); + customer.OrderApprovalLimit = 100; + + await _handler.Handle(Command(role: SubAccountRole.Buyer, limit: null), default); + + Assert.IsNull(customer.OrderApprovalLimit); + _customerServiceMock.Verify(c => c.UpdateCustomerField(customer, + It.IsAny>>(), (double?)null), Times.Once); + } + + [TestMethod] + public async Task Handle_Approver_ClearsLimit() + { + var customer = SubAccount(); + customer.OrderApprovalLimit = 100; + + await _handler.Handle(Command(role: SubAccountRole.Approver, limit: "500"), default); + + Assert.AreEqual(SubAccountRole.Approver, customer.SubAccountRole); + Assert.IsNull(customer.OrderApprovalLimit); + _customerServiceMock.Verify(c => c.UpdateCustomerField(customer, + It.IsAny>>(), (double?)null), Times.Once); } [TestMethod] 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..a9e5f97d76 --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Commands/Orders/OrderApprovalCommandsTests.cs @@ -0,0 +1,703 @@ +using Grand.Business.Core.Commands.Checkout.Orders; +using Grand.Business.Core.Commands.Messages.Common; +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.Payments; +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.Extensions.Logging; +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 Mock _orders; + private Mock _mediator; + private Mock> _logger; + private Order _order; + + 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, + OrderId = "o1", Amount = 150 + }; + + 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" + }; + + [TestInitialize] + public void Init() + { + _approvals = new Mock(); + _messages = new Mock(); + _customers = new Mock(); + _mediator = new Mock(); + _logger = new Mock>(); + _order = new Order { + Id = "o1", CustomerId = "buyer", OwnerId = OwnerId, StoreId = StoreId, + OrderStatusId = (int)OrderStatusSystem.AwaitingApproval + }; + _orders = new Mock(); + _orders.Setup(o => o.GetOrderById("o1")).ReturnsAsync(() => _order); + } + + + + #region Decide + + private DecideOrderApprovalCommandHandler Decider() + { + _customers.Setup(c => c.GetCustomerById("buyer")).ReturnsAsync(Buyer()); + return new DecideOrderApprovalCommandHandler(_approvals.Object, _customers.Object, _messages.Object, + _orders.Object, _mediator.Object, _logger.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_SendsTheDecisionInTheBuyersLanguage() + { + // the approver works in English, the buyer placed the order in Polish + _order.CustomerLanguageId = "pl"; + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(Approval()); + + var result = await Decider().Handle(DecideCommand(SubAccount("approver", SubAccountRole.Approver)), default); + + Assert.IsTrue(result); + _messages.Verify(m => m.SendOrderApprovalDecidedMessage(It.IsAny(), It.IsAny(), + It.IsAny(), "pl", 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_BuyerPromotedToApprover_CannotDecideOwnRequest() + { + // the buyer placed the order and was made an approver afterwards + var approval = Approval(); + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(approval); + var promoted = SubAccount("buyer", SubAccountRole.Approver); + + var result = await Decider().Handle(DecideCommand(promoted), default); + + Assert.IsFalse(result); + Assert.AreEqual(OrderApprovalStatus.Pending, approval.Status); + _approvals.Verify(a => a.Update(It.IsAny()), Times.Never); + _mediator.Verify(m => m.Send(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.Cancelled)] + 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, _orders.Object, _mediator.Object, + new Mock>().Object); + + [TestMethod] + public async Task Withdraw_Own_Withdrawn() + { + var approval = Approval(); + _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.Withdrawn)] + [DataRow(OrderApprovalStatus.Cancelled)] + 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 + + #region Orders awaiting approval + + private List Sent() + { + var sent = new List(); + _mediator.Setup(m => m.Send(It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>((c, _) => sent.Add(c)).ReturnsAsync(true); + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .Callback((c, _) => sent.Add(c)).Returns(Task.CompletedTask); + return sent; + } + + [TestMethod] + public async Task Approve_MovesOrderToPending_AndNotifiesStoreOwnerOnly() + { + var sent = Sent(); + var approval = Approval(); + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(approval); + + var result = await Decider().Handle(DecideCommand(new Customer { Id = OwnerId }), default); + + Assert.IsTrue(result); + var status = sent.OfType().Single(); + Assert.AreEqual(OrderStatusSystem.Pending, status.Os); + Assert.IsFalse(status.NotifyCustomer); + Assert.IsFalse(status.NotifyStoreOwner); + Assert.AreSame(_order, status.Order); + Assert.AreEqual(1, sent.OfType().Count()); + var note = sent.OfType().Single(); + Assert.IsFalse(note.NotifyCustomer); + Assert.IsTrue(note.NotifyStoreOwnerAndVendors); + Assert.AreEqual("buyer", note.Customer.Id); + // the order got its "Order placed" note at placement + Assert.IsFalse(note.AddOrderPlacedNote); + Assert.AreEqual(0, sent.OfType().Count()); + Assert.AreEqual(0, sent.OfType().Count()); + // statuses first, then the e-mails + Assert.IsTrue(sent.IndexOf(status) < sent.IndexOf(note)); + } + + [TestMethod] + public async Task Approve_AddsOneOrderApprovedNote_HiddenFromTheCustomer() + { + Sent(); + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(Approval()); + + await Decider().Handle(DecideCommand(new Customer { Id = OwnerId }), default); + + _orders.Verify(o => o.InsertOrderNote(It.Is(n => + n.OrderId == _order.Id && n.Note == "Order approved" && !n.DisplayToCustomer)), Times.Once); + } + + [TestMethod] + public async Task Reject_AddsNoOrderApprovedNote() + { + Sent(); + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(Approval()); + + await Decider().Handle(DecideCommand(new Customer { Id = OwnerId }, false), default); + + _orders.Verify(o => o.InsertOrderNote(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Approve_PaidOrder_ProcessesItAsPaid() + { + var sent = Sent(); + _order.PaymentStatusId = PaymentStatus.Paid; + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(Approval()); + + await Decider().Handle(DecideCommand(new Customer { Id = OwnerId }), default); + + Assert.AreEqual(1, sent.OfType().Count()); + } + + [TestMethod] + public async Task Approve_OrderNoLongerAwaiting_Refused() + { + var sent = Sent(); + _order.OrderStatusId = (int)OrderStatusSystem.Cancelled; + var approval = Approval(); + _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(OrderApprovalStatus.Pending, approval.Status); + Assert.AreEqual(0, sent.Count); + _approvals.Verify(a => a.Update(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Approve_OrderMissing_Refused() + { + _order = null; + _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 Approve_NotificationFails_DecisionStands() + { + Sent(); + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("smtp")); + _messages.Setup(m => m.SendOrderApprovalDecidedMessage(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())).ThrowsAsync(new Exception("smtp")); + var approval = Approval(); + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(approval); + + var result = await Decider().Handle(DecideCommand(new Customer { Id = OwnerId }), default); + + Assert.IsTrue(result); + Assert.AreEqual(OrderApprovalStatus.Approved, approval.Status); + } + + [TestMethod] + public async Task Reject_CancelsOrderWithoutNotifyingAndTellsTheBuyer() + { + var sent = Sent(); + var approval = Approval(); + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(approval); + + var result = await Decider().Handle(DecideCommand(new Customer { Id = OwnerId }, false), default); + + Assert.IsTrue(result); + Assert.AreEqual(OrderApprovalStatus.Rejected, approval.Status); + var cancel = sent.OfType().Single(); + Assert.IsFalse(cancel.NotifyCustomer); + Assert.IsFalse(cancel.NotifyStoreOwner); + Assert.AreSame(_order, cancel.Order); + Assert.AreEqual(0, sent.OfType().Count()); + Assert.AreEqual(0, sent.OfType().Count()); + _messages.Verify(m => m.SendOrderApprovalDecidedMessage(approval, It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task Reject_CancelThrows_ApprovalStaysPendingAndFailureIsReported() + { + Sent(); + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("boom")); + var approval = Approval(); + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(approval); + + var result = await Decider().Handle(DecideCommand(new Customer { Id = OwnerId }, false), 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 Withdraw_CancelThrows_ApprovalStaysPendingAndFailureIsReported() + { + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("boom")); + var approval = Approval(); + _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] + public async Task Approve_SetStatusThrows_ApprovalStaysPending() + { + var sent = Sent(); + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("boom")); + var approval = Approval(); + _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(OrderApprovalStatus.Pending, approval.Status); + _approvals.Verify(a => a.Update(It.IsAny()), Times.Never); + Assert.AreEqual(0, sent.Count); + } + + [TestMethod] + public async Task Approve_ProcessPaidThrows_DecisionStandsAndIsLogged() + { + Sent(); + _order.PaymentStatusId = PaymentStatus.Paid; + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("boom")); + var approval = Approval(); + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(approval); + + var result = await Decider().Handle(DecideCommand(new Customer { Id = OwnerId }), default); + + Assert.IsTrue(result); + Assert.AreEqual(OrderApprovalStatus.Approved, approval.Status); + _logger.Verify(l => l.Log(LogLevel.Error, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>()), Times.Once); + _messages.Verify(m => m.SendOrderApprovalDecidedMessage(approval, It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task Reject_OrderNoLongerAwaiting_Refused() + { + var sent = Sent(); + _order.OrderStatusId = (int)OrderStatusSystem.Pending; + var approval = Approval(); + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(approval); + + var result = await Decider().Handle(DecideCommand(new Customer { Id = OwnerId }, false), default); + + Assert.IsFalse(result); + Assert.AreEqual(OrderApprovalStatus.Pending, approval.Status); + Assert.AreEqual(0, sent.Count); + } + + [TestMethod] + public async Task Decide_ApproverOfAnotherCompany_CannotTouchTheOrder() + { + var sent = Sent(); + var approval = Approval(); + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(approval); + var foreign = new Customer { Id = "ownerB", Active = true }; + + Assert.IsFalse(await Decider().Handle(DecideCommand(foreign), default)); + + Assert.AreEqual(OrderApprovalStatus.Pending, approval.Status); + Assert.AreEqual(0, sent.Count); + } + + [TestMethod] + public async Task Decide_BuyerApprovingOwnOrder_Refused() + { + var sent = Sent(); + var approval = Approval(); + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(approval); + + Assert.IsFalse(await Decider().Handle(DecideCommand(Buyer()), default)); + + Assert.AreEqual(OrderApprovalStatus.Pending, approval.Status); + Assert.AreEqual(0, sent.Count); + } + + [TestMethod] + public async Task TwoOrdersOfOneBuyer_AreDecidedIndependently() + { + var sent = Sent(); + var order2 = new Order { + Id = "o2", CustomerId = "buyer", OwnerId = OwnerId, StoreId = StoreId, + OrderStatusId = (int)OrderStatusSystem.AwaitingApproval + }; + _orders.Setup(o => o.GetOrderById("o2")).ReturnsAsync(order2); + var first = Approval(id: "a1"); + var second = Approval(id: "a2"); + second.OrderId = "o2"; + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(first); + _approvals.Setup(a => a.GetById("a2", OwnerId)).ReturnsAsync(second); + var owner = new Customer { Id = OwnerId, Active = true }; + + Assert.IsTrue(await Decider().Handle(DecideCommand(owner, true, id: "a1"), default)); + + Assert.AreEqual(OrderApprovalStatus.Approved, first.Status); + Assert.AreEqual(OrderApprovalStatus.Pending, second.Status); + Assert.AreEqual((int)OrderStatusSystem.AwaitingApproval, order2.OrderStatusId); + Assert.IsTrue(sent.OfType().All(c => c.Order.Id == "o1")); + + Assert.IsTrue(await Decider().Handle(DecideCommand(owner, false, id: "a2"), default)); + Assert.AreEqual(OrderApprovalStatus.Rejected, second.Status); + Assert.AreEqual("o2", sent.OfType().Single().Order.Id); + } + + [TestMethod] + public async Task Withdraw_CancelsTheOrderWithoutNotifying() + { + var sent = Sent(); + var approval = Approval(); + _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); + var cancel = sent.OfType().Single(); + Assert.IsFalse(cancel.NotifyCustomer); + Assert.AreSame(_order, cancel.Order); + } + + [TestMethod] + public async Task Withdraw_OrderNoLongerAwaiting_Refused() + { + var sent = Sent(); + _order.OrderStatusId = (int)OrderStatusSystem.Pending; + var approval = Approval(); + _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); + Assert.AreEqual(0, sent.Count); + } + + #endregion + + #region Cancel closes a pending request + + // the stored request, as the database keeps it: every read gets its own copy, every save replaces it + private OrderApproval _stored; + + private static OrderApproval Copy(OrderApproval a) => new() { + Id = a.Id, CustomerId = a.CustomerId, CompanyOwnerId = a.CompanyOwnerId, StoreId = a.StoreId, + Status = a.Status, OrderId = a.OrderId, Amount = a.Amount, DecidedOnUtc = a.DecidedOnUtc, + DecidedByCustomerId = a.DecidedByCustomerId, DecisionComment = a.DecisionComment + }; + + // the cancel publishes OrderCancelledEvent, whose handler closes the still pending request + private void StoreBehindTheCancel() + { + _stored = Approval(); + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(() => Copy(_stored)); + _approvals.Setup(a => a.GetByOrderId("o1")).ReturnsAsync(() => Copy(_stored)); + _approvals.Setup(a => a.Update(It.IsAny())) + .Callback(a => _stored = Copy(a)).Returns(Task.CompletedTask); + var cancelled = new Grand.Business.Checkout.Events.Orders.OrderCancelledOrderApprovalHandler(_approvals.Object, + new Mock>().Object); + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .Returns(async (IRequest command, CancellationToken token) => + { + var order = ((CancelOrderCommand)command).Order; + order.OrderStatusId = (int)OrderStatusSystem.Cancelled; + await cancelled.Handle(new Grand.Business.Core.Events.Checkout.Orders.OrderCancelledEvent(order), + token); + return true; + }); + } + + [TestMethod] + public async Task Decide_Reject_StoresRejectedOverTheCancelClose() + { + StoreBehindTheCancel(); + + var result = await Decider().Handle(DecideCommand(new Customer { Id = OwnerId }, false), default); + + Assert.IsTrue(result); + Assert.AreEqual(OrderApprovalStatus.Rejected, _stored.Status); + Assert.AreEqual(OwnerId, _stored.DecidedByCustomerId); + _approvals.Verify(a => a.Update(It.IsAny()), Times.Exactly(2)); + } + + [TestMethod] + public async Task Withdraw_StoresWithdrawnOverTheCancelClose() + { + StoreBehindTheCancel(); + + var result = await Withdrawer().Handle( + new WithdrawOrderApprovalCommand { Customer = Buyer(), Store = Shop(), ApprovalId = "a1" }, default); + + Assert.IsTrue(result); + Assert.AreEqual(OrderApprovalStatus.Withdrawn, _stored.Status); + _approvals.Verify(a => a.Update(It.IsAny()), Times.Exactly(2)); + } + + #endregion +} diff --git a/src/Tests/Grand.Web.Tests/Controllers/CheckoutControllerOrderApprovalTests.cs b/src/Tests/Grand.Web.Tests/Controllers/CheckoutControllerOrderApprovalTests.cs new file mode 100644 index 0000000000..42387b12c5 --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Controllers/CheckoutControllerOrderApprovalTests.cs @@ -0,0 +1,96 @@ +using System.Text.Json; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Checkout.Payments; +using Grand.Business.Core.Interfaces.Checkout.Shipping; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Utilities.Checkout; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Domain.Directory; +using Grand.Domain.Localization; +using Grand.Domain.Orders; +using Grand.Domain.Payments; +using Grand.Domain.Shipping; +using Grand.Domain.Tax; +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; +using Grand.Mediator; +using Grand.Web.Common.Localization; +using Grand.Web.Controllers; +using Grand.Web.Features.Models.Checkout; +using Grand.Web.Models.Checkout; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Tests.Controllers; + +[TestClass] +public class CheckoutControllerOrderApprovalTests +{ + private Mock _approval; + private CheckoutController _controller; + private Mock _customerService; + private Mock _paymentService; + + [TestInitialize] + public void Init() + { + var customer = new Customer { Id = "buyer", OwnerId = "owner" }; + var store = new Grand.Domain.Stores.Store { Id = "s1" }; + 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(new Currency { Id = "c1", CurrencyCode = "EUR" }); + work.Setup(w => w.TaxDisplayType).Returns(TaxDisplayType.IncludingTax); + var storeContext = new Mock(); + storeContext.Setup(s => s.CurrentStore).Returns(store); + var context = new Mock(); + context.Setup(c => c.WorkContext).Returns(work.Object); + context.Setup(c => c.StoreContext).Returns(storeContext.Object); + + var cart = new List { new() { ProductId = "p1", Quantity = 1 } }; + var cartService = new Mock(); + cartService.Setup(s => s.GetShoppingCart(customer, "s1", It.IsAny())) + .ReturnsAsync(cart); + + var translation = new Mock(); + translation.Setup(t => t.GetResource(It.IsAny())).Returns((string key) => key); + + var mediator = new Mock(); + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + _approval = new Mock(); + _approval.Setup(a => a.RequiresApproval(cart, It.IsAny())) + .ReturnsAsync(new OrderApprovalCheckoutState(true, 100, 500)); + _customerService = new Mock(); + _paymentService = new Mock(); + + _controller = new CheckoutController(context.Object, translation.Object, _customerService.Object, + new Mock().Object, cartService.Object, new Mock().Object, + new Mock().Object, _paymentService.Object, + new Mock().Object, new Mock>().Object, + new Mock().Object, mediator.Object, new Mock().Object, + new Mock().Object, _approval.Object, new OrderSettings(), new OidcLoginConfig(), + new LoyaltyPointsSettings(), new PaymentSettings(), new ShippingSettings(), new AddressSettings()); + } + + [TestMethod] + public async Task SavePaymentMethod_ApprovalRequiredAndMethodNotAllowed_RefusesWithoutSaving() + { + _approval.Setup(a => a.IsPaymentMethodAllowed("Payments.Card")).Returns(false); + + var result = await _controller.SavePaymentMethod(new CheckoutPaymentMethodModel { PaymentMethod = "Payments.Card" }); + + var json = JsonSerializer.Serialize(((JsonResult)result).Value); + StringAssert.Contains(json, "\"error\":1"); + StringAssert.Contains(json, "Checkout.OrderApproval.PaymentMethodNotAllowed"); + _paymentService.Verify(p => p.LoadPaymentMethodBySystemName(It.IsAny()), Times.Never); + _customerService.Verify(c => c.UpdateUserField(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } +} 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..d58b80460c --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Controllers/OrderApprovalControllerTests.cs @@ -0,0 +1,245 @@ +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.Decide("a1", true, null), "CustomerInfo"); + AssertRedirectTo(await _controller.Withdraw("a1"), "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); + } + + //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); + } + + //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_SendsTheCurrentCustomersCommand() + { + 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"); + + AssertRedirectTo(result, "CustomerOrderApprovals"); + 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 Decide_WithoutApproveValue_IsRefusedAndNothingIsSent() + { + var result = await _controller.Decide("a1", null, "ok"); + + AssertRedirectTo(result, "CustomerOrderApprovals"); + CollectionAssert.AreEqual(new[] { "OrderApproval.Decide.Refused" }, Notifications("Error").ToArray()); + _mediator.Verify(m => m.Send(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task Withdraw_ReturnsToTheList() + { + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + AssertRedirectTo(await _controller.Withdraw("a1"), "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"); + + AssertRedirectTo(result, "CustomerOrderApprovals"); + CollectionAssert.AreEqual(new[] { "OrderApproval.Withdraw.Refused" }, Notifications("Error").ToArray()); + } +} diff --git a/src/Tests/Grand.Web.Tests/Features/Customers/GetSubAccountHandlerTests.cs b/src/Tests/Grand.Web.Tests/Features/Customers/GetSubAccountHandlerTests.cs index 658558cd9f..f8dc1c1828 100644 --- a/src/Tests/Grand.Web.Tests/Features/Customers/GetSubAccountHandlerTests.cs +++ b/src/Tests/Grand.Web.Tests/Features/Customers/GetSubAccountHandlerTests.cs @@ -4,6 +4,7 @@ using Grand.Web.Features.Models.Customers; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; +using System.Globalization; namespace Grand.Web.Tests.Features.Customers; @@ -37,6 +38,68 @@ public async Task Handle_OwnSubAccount_ReturnsIt() Assert.IsTrue(model.Active); } + [TestMethod] + public async Task Get_ReturnsRoleAndLimit() + { + var previous = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = new CultureInfo("en-US"); + try + { + await ReturnsRoleAndLimit(); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } + + private async Task ReturnsRoleAndLimit() + { + _customerServiceMock.Setup(c => c.GetCustomerById("sub")).ReturnsAsync(new Customer { + Id = "sub", OwnerId = "owner", Email = "sub@x.com", + SubAccountRole = SubAccountRole.Approver, OrderApprovalLimit = 750.25 + }); + + var model = await _handler.Handle(Query("sub"), default); + + Assert.AreEqual(SubAccountRole.Approver, model.Role); + Assert.AreEqual("750.25", model.OrderApprovalLimit); + } + + [TestMethod] + [DataRow("pl-PL", "1500,5")] + [DataRow("de-DE", "1500,5")] + [DataRow("en-US", "1500.5")] + public async Task Get_FormatsTheLimitInTheCurrentCulture(string culture, string expected) + { + var previous = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo(culture); + _customerServiceMock.Setup(c => c.GetCustomerById("sub")).ReturnsAsync( + new Customer { Id = "sub", OwnerId = "owner", Email = "sub@x.com", OrderApprovalLimit = 1500.5 }); + + var model = await _handler.Handle(Query("sub"), default); + + Assert.AreEqual(expected, model.OrderApprovalLimit); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } + + [TestMethod] + public async Task Get_NoLimit_IsEmptyText() + { + _customerServiceMock.Setup(c => c.GetCustomerById("sub")) + .ReturnsAsync(new Customer { Id = "sub", OwnerId = "owner", Email = "sub@x.com" }); + + var model = await _handler.Handle(Query("sub"), default); + + Assert.AreEqual(string.Empty, model.OrderApprovalLimit); + } + [TestMethod] [DataRow("another-owner", false)] [DataRow("", false)] diff --git a/src/Tests/Grand.Web.Tests/Features/Handlers/Checkout/CheckoutOrderApprovalHandlersTests.cs b/src/Tests/Grand.Web.Tests/Features/Handlers/Checkout/CheckoutOrderApprovalHandlersTests.cs new file mode 100644 index 0000000000..c57141df91 --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Features/Handlers/Checkout/CheckoutOrderApprovalHandlersTests.cs @@ -0,0 +1,172 @@ +using Grand.Business.Core.Enums.Checkout; +using Grand.Domain.Shipping; +using Grand.Business.Core.Interfaces.Catalog.Prices; +using Grand.Business.Core.Interfaces.Catalog.Tax; +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.Orders; +using Grand.Domain.Payments; +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.Handlers.Checkout; + +[TestClass] +public class CheckoutOrderApprovalHandlersTests +{ + private Mock _approval; + private IList _cart; + private Currency _currency; + private Customer _customer; + private Mock _paymentService; + private Grand.Domain.Stores.Store _store; + private Mock _translation; + + [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" }; + _cart = new List { new() { ProductId = "p1", Quantity = 1 } }; + _approval = new Mock(); + _paymentService = new Mock(); + _translation = new Mock(); + _translation.Setup(t => t.GetResource(It.IsAny())).Returns((string key) => key); + _translation.Setup(t => t.GetResource("Checkout.OrderApproval.WillBeSent")).Returns("Over {0} goes for approval"); + } + + private void State(bool required, double? limit = 100) => + _approval.Setup(a => a.RequiresApproval(_cart, It.Is(c => c.Customer == _customer))) + .ReturnsAsync(new OrderApprovalCheckoutState(required, limit, 500)); + + private static Mock Provider(string systemName) + { + var provider = new Mock(); + provider.Setup(p => p.SystemName).Returns(systemName); + provider.Setup(p => p.FriendlyName).Returns(systemName); + provider.Setup(p => p.PaymentMethodType).Returns(PaymentMethodType.Standard); + provider.Setup(p => p.HidePaymentMethod(It.IsAny>())).ReturnsAsync(false); + provider.Setup(p => p.Description()).ReturnsAsync(string.Empty); + return provider; + } + + private GetPaymentMethodHandler PaymentHandler(params string[] methods) + { + _paymentService.Setup(p => p.LoadActivePaymentMethods(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(methods.Select(m => Provider(m).Object).ToList()); + var tax = new Mock(); + tax.Setup(t => t.GetPaymentMethodAdditionalFee(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).ReturnsAsync((0d, 0d)); + return new GetPaymentMethodHandler(new Mock().Object, + new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, _paymentService.Object, + tax.Object, _approval.Object, _translation.Object, new LoyaltyPointsSettings(), new PaymentSettings()); + } + + private GetPaymentMethod PaymentRequest() => new() { + Customer = _customer, Store = _store, Currency = _currency, TaxDisplayType = TaxDisplayType.IncludingTax, + Cart = _cart + }; + + [TestMethod] + public async Task PaymentMethods_ApprovalRequired_KeepsOnlyAllowedMethods() + { + State(true); + _approval.Setup(a => a.IsPaymentMethodAllowed("Payments.CashOnDelivery")).Returns(true); + + var model = await PaymentHandler("Payments.Card", "Payments.CashOnDelivery") + .Handle(PaymentRequest(), CancellationToken.None); + + Assert.HasCount(1, model.PaymentMethods); + Assert.AreEqual("Payments.CashOnDelivery", model.PaymentMethods[0].PaymentMethodSystemName); + Assert.IsTrue(model.PaymentMethods[0].Selected); + Assert.IsNull(model.NoPaymentMethodMessage); + } + + [TestMethod] + public async Task PaymentMethods_ApprovalRequiredAndNoneAllowed_ShowsTheMessage() + { + State(true); + + var model = await PaymentHandler("Payments.Card").Handle(PaymentRequest(), CancellationToken.None); + + Assert.IsEmpty(model.PaymentMethods); + Assert.AreEqual("Checkout.OrderApproval.NoPaymentMethod", model.NoPaymentMethodMessage); + } + + [TestMethod] + public async Task PaymentMethods_ApprovalNotRequired_KeepsEveryMethod() + { + State(false); + + var model = await PaymentHandler("Payments.Card", "Payments.CashOnDelivery") + .Handle(PaymentRequest(), CancellationToken.None); + + Assert.HasCount(2, model.PaymentMethods); + Assert.IsNull(model.NoPaymentMethodMessage); + _approval.Verify(a => a.IsPaymentMethodAllowed(It.IsAny()), Times.Never); + } + + private GetConfirmOrderHandler ConfirmHandler() + { + var priceFormatter = new Mock(); + priceFormatter.Setup(p => p.FormatPrice(It.IsAny(), _currency)).Returns((p, _) => $"{p:0} EUR"); + var currencyService = new Mock(); + currencyService.Setup(c => c.ConvertFromPrimaryStoreCurrency(It.IsAny(), _currency)) + .ReturnsAsync((double v, Currency _) => v * 2); + return new GetConfirmOrderHandler(new Mock().Object, _paymentService.Object, + new Mock().Object, _approval.Object, currencyService.Object, priceFormatter.Object, + _translation.Object, new OrderSettings(), new ShippingSettings()); + } + + private GetConfirmOrder ConfirmRequest() => new() { + Customer = _customer, Store = _store, Currency = _currency, TaxDisplayType = TaxDisplayType.IncludingTax, + Cart = _cart + }; + + [TestMethod] + public async Task Confirm_ApprovalRequired_CarriesTheMessageWithTheFormattedLimitAndTheButtonText() + { + State(true, 100); + + var model = await ConfirmHandler().Handle(ConfirmRequest(), CancellationToken.None); + + Assert.IsTrue(model.OrderApprovalRequired); + Assert.AreEqual("Over 200 EUR goes for approval", model.OrderApprovalMessage); + Assert.AreEqual("Checkout.OrderApproval.SendButton", model.OrderApprovalButtonText); + } + + [TestMethod] + public async Task Confirm_BrokenPlaceholderInTheEditedText_StillRendersTheText() + { + _translation.Setup(t => t.GetResource("Checkout.OrderApproval.WillBeSent")).Returns("Over {0 goes"); + State(true, 100); + + var model = await ConfirmHandler().Handle(ConfirmRequest(), CancellationToken.None); + + Assert.IsTrue(model.OrderApprovalRequired); + Assert.AreEqual("Over {0 goes", model.OrderApprovalMessage); + } + + [TestMethod] + public async Task Confirm_ApprovalNotRequired_LeavesTheStateEmpty() + { + State(false); + + var model = await ConfirmHandler().Handle(ConfirmRequest(), CancellationToken.None); + + Assert.IsFalse(model.OrderApprovalRequired); + Assert.IsNull(model.OrderApprovalMessage); + Assert.IsNull(model.OrderApprovalButtonText); + } +} 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..23712ad8c1 --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Features/Handlers/Orders/GetOrderApprovalHandlersTests.cs @@ -0,0 +1,271 @@ +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 Mock _orders; + 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(); + _orders = new Mock(); + var placed = new Order { Id = "o1", OrderNumber = 1007, CustomerId = "buyer", OwnerId = "owner" }; + _orders.Setup(o => o.GetOrderById("o1")).ReturnsAsync(placed); + _orders.Setup(o => o.GetOrdersByIds(It.IsAny())).ReturnsAsync([placed]); + 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, _orders.Object, dates.Object, + enums.Object); + _listHandler = new GetOrderApprovalsHandler(_approvals.Object, _customers.Object, _orders.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, OrderId = "o1", + 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_ShowsTheOrderNumber_AndTheBuyerMayOpenTheOrder() + { + Found(Approval()); + + var model = await Details(new Customer { Id = "buyer", OwnerId = "owner" }); + + Assert.AreEqual("o1", model.OrderId); + Assert.AreEqual(1007, model.OrderNumber); + Assert.IsTrue(model.CanViewOrder); + } + + [TestMethod] + public async Task Details_ApproverSubAccount_SeesTheNumberButCannotOpenTheOrder() + { + Found(Approval()); + _customers.Setup(c => c.GetCustomerById("buyer")).ReturnsAsync(new Customer { Id = "buyer" }); + + var model = await Details(new Customer { Id = "appr", OwnerId = "owner", SubAccountRole = SubAccountRole.Approver }); + + Assert.AreEqual(1007, model.OrderNumber); + Assert.IsFalse(model.CanViewOrder); + } + + [TestMethod] + public async Task List_RowsCarryTheOrderNumber() + { + _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.AreEqual(1007, model.Approvals[0].OrderNumber); + Assert.AreEqual("o1", model.Approvals[0].OrderId); + Assert.IsTrue(model.Approvals[0].CanViewOrder); + } + + [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_BuyerPromotedToApprover_CannotDecideOwnRequest() + { + Found(Approval()); + + var model = await Details(new Customer { Id = "buyer", OwnerId = "owner", SubAccountRole = SubAccountRole.Approver }); + + Assert.IsNotNull(model); + Assert.IsFalse(model.CanDecide); + Assert.IsTrue(model.CanWithdraw); + } + + [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_ApprovedRequest_CannotBeWithdrawn() + { + Found(Approval(status: OrderApprovalStatus.Approved)); + + 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/Validators/Customer/SubAccountEditValidatorTests.cs b/src/Tests/Grand.Web.Tests/Validators/Customer/SubAccountEditValidatorTests.cs index bae32c9672..025a2b5898 100644 --- a/src/Tests/Grand.Web.Tests/Validators/Customer/SubAccountEditValidatorTests.cs +++ b/src/Tests/Grand.Web.Tests/Validators/Customer/SubAccountEditValidatorTests.cs @@ -9,6 +9,7 @@ using Grand.Web.Validators.Customer; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; +using System.Globalization; namespace Grand.Web.Tests.Validators.Customers; @@ -22,6 +23,8 @@ public class SubAccountEditValidatorTests [TestInitialize] public void Init() { + _culture = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = new CultureInfo("en-US"); _customerService = new Mock(); _groupService = new Mock(); _groupService.Setup(g => g.IsRegistered(It.IsAny())).ReturnsAsync(true); @@ -41,6 +44,11 @@ public void Init() new CustomerSettings { AllowUsersToChangeEmail = true }, new CustomerConfig()); } + private CultureInfo _culture; + + [TestCleanup] + public void Restore() => CultureInfo.CurrentCulture = _culture; + private static SubAccountEditModel Model(string email = "new@x.com") => new() { Id = "c1", Email = email, FirstName = "A", LastName = "B" }; @@ -95,6 +103,60 @@ public async Task NotRegisteredSubAccount_UsesTheResourceNotARawKey() Assert.IsTrue(result.Errors.Any(e => e.ErrorMessage == "Account.Login.WrongCredentials.NotRegistered")); } + private void OwnSubAccount() => _customerService.Setup(c => c.GetCustomerById("c1")) + .ReturnsAsync(new Customer { Id = "c1", OwnerId = "owner", Email = "new@x.com" }); + + [TestMethod] + [DataRow("en-US", "-0.01")] + [DataRow("en-US", "NaN")] + [DataRow("en-US", "Infinity")] + [DataRow("pl-PL", "-1")] + [DataRow("pl-PL", "abc")] + [DataRow("pl-PL", "12,5,5")] + public async Task Validate_NegativeLimit_Fails(string culture, string limit) + { + CultureInfo.CurrentCulture = new CultureInfo(culture); + OwnSubAccount(); + var model = Model(); + model.OrderApprovalLimit = limit; + + var result = await _validator.ValidateAsync(model); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Any(e => e.ErrorMessage == "Account.SubAccounts.Fields.OrderApprovalLimit.Invalid")); + } + + [TestMethod] + [DataRow("en-US", null)] + [DataRow("en-US", "")] + [DataRow("en-US", "0")] + [DataRow("en-US", "250.5")] + [DataRow("pl-PL", "250,5")] + [DataRow("de-DE", "1.500,50")] + public async Task Validate_EmptyZeroOrPositiveLimit_Passes(string culture, string limit) + { + CultureInfo.CurrentCulture = new CultureInfo(culture); + OwnSubAccount(); + var model = Model(); + model.OrderApprovalLimit = limit; + + var result = await _validator.ValidateAsync(model); + + Assert.IsTrue(result.IsValid); + } + + [TestMethod] + public async Task Validate_UnknownRole_Fails() + { + OwnSubAccount(); + var model = Model(); + model.Role = (SubAccountRole)5; + + var result = await _validator.ValidateAsync(model); + + Assert.IsTrue(result.Errors.Any(e => e.ErrorMessage == "Account.SubAccounts.Fields.Role.Invalid")); + } + [TestMethod] public async Task EmptyEmail_FailsValidationWithoutThrowing() { diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Payment/Settings.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Payment/Settings.cshtml index 20fffed2ab..548223c1ca 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Payment/Settings.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Payment/Settings.cshtml @@ -28,6 +28,12 @@ + @* one global list, not per store: orders that wait for a company approver are paid only with these methods *@ + + + + + diff --git a/src/Web/Grand.Web.Admin/Controllers/PaymentController.cs b/src/Web/Grand.Web.Admin/Controllers/PaymentController.cs index e0bf5d671e..a3013516a9 100644 --- a/src/Web/Grand.Web.Admin/Controllers/PaymentController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/PaymentController.cs @@ -3,6 +3,7 @@ using Grand.Business.Core.Interfaces.Checkout.Shipping; using Grand.Business.Core.Interfaces.Common.Configuration; using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Domain.Orders; using Grand.Domain.Permissions; using Grand.Domain.Payments; using Grand.Infrastructure.Plugins; @@ -14,6 +15,7 @@ using Grand.Web.Common.DataSource; using Grand.Web.Common.Security.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; using Grand.Web.Common.Localization; namespace Grand.Web.Admin.Controllers; @@ -230,6 +232,20 @@ public async Task Settings() var paymentSettings = await _settingService.LoadSetting(storeScope); var model = paymentSettings.ToModel(); + // the order approval methods are one global list, whatever store is chosen above + var approvalSettings = await _settingService.LoadSetting(); + model.OrderApprovalPaymentMethodSystemNames = approvalSettings.PaymentMethodSystemNames.ToList(); + foreach (var method in await _paymentService.LoadAllPaymentMethods()) + if (method.IsPaymentMethodActive(paymentSettings)) + model.AvailableOrderApprovalPaymentMethods.Add(new SelectListItem { + Text = $"{method.FriendlyName} ({method.SystemName})", Value = method.SystemName + }); + + // a chosen method that is no longer active stays visible, so saving does not drop it unseen + foreach (var name in model.OrderApprovalPaymentMethodSystemNames) + if (model.AvailableOrderApprovalPaymentMethods.All(x => x.Value != name)) + model.AvailableOrderApprovalPaymentMethods.Add(new SelectListItem { Text = name, Value = name }); + return View(model); } @@ -243,6 +259,11 @@ public async Task Settings(PaymentSettingsModel model) await _settingService.SaveSetting(paymentSettings, storeScope); + var approvalSettings = await _settingService.LoadSetting(); + approvalSettings.PaymentMethodSystemNames = (model.OrderApprovalPaymentMethodSystemNames ?? []) + .Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList(); + await _settingService.SaveSetting(approvalSettings); + Success(_translationService.GetResource("Admin.Configuration.Updated")); return RedirectToAction("Settings"); } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseOrderManagementController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseOrderManagementController.cs index 6bd92cde98..7c63ef23e0 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseOrderManagementController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseOrderManagementController.cs @@ -98,6 +98,20 @@ public async Task ChangeOrderStatus(string id, OrderModel model) var (order, denied) = await LoadAuthorizedOrder(id); if (denied != null) return denied; + //an order leaves Awaiting approval through the approver's decision, the buyer's withdrawal or a cancel, + //which also close its approval request; only the checkout puts an order into that status + if (order.OrderStatusId == (int)OrderStatusSystem.AwaitingApproval) + { + Error(TranslationService.GetResource("Admin.Orders.Fields.OrderStatus.Change.AwaitingApproval")); + return RedirectToAction("Edit", "Order", new { id }); + } + + if (model.OrderStatusId == (int)OrderStatusSystem.AwaitingApproval) + { + Error(TranslationService.GetResource("Admin.Orders.Fields.OrderStatus.Change.ToAwaitingApproval")); + return RedirectToAction("Edit", "Order", new { id }); + } + try { var status = await orderStatusService.GetByStatusId(model.OrderStatusId); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs index 1c1aa615f7..a806053b4d 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseShipmentController.cs @@ -152,6 +152,19 @@ public async Task ShipmentsItemsByShipmentId(string shipmentId, D #region AddShipment + /// + /// An order awaiting approval may still be rejected or withdrawn, and a shipment would block the cancel + /// that both run; nothing ships before the approver decides + /// + private IActionResult RefuseOrderAwaitingApproval(Order order) + { + if (order.OrderStatusId != (int)OrderStatusSystem.AwaitingApproval) + return null; + + Error(translationService.GetResource("Admin.Orders.Shipments.OrderAwaitsApproval")); + return RedirectToAction("Edit", "Order", new { id = order.Id }); + } + [PermissionAuthorizeAction(PermissionActionName.Create)] public async Task AddShipment(string orderId) { @@ -160,6 +173,9 @@ public async Task AddShipment(string orderId) //No order found with the specified id return RedirectToAction("List"); + var awaitingApproval = RefuseOrderAwaitingApproval(order); + if (awaitingApproval != null) return awaitingApproval; + var model = await shipmentViewModelService.PrepareShipmentModel(order); return View(model); } @@ -174,6 +190,9 @@ public async Task AddShipment(AddShipmentModel model, bool contin //No order found with the specified id return RedirectToAction("List"); + var awaitingApproval = RefuseOrderAwaitingApproval(order); + if (awaitingApproval != null) return awaitingApproval; + var orderItems = scope.FilterOrderItems(order.OrderItems).ToList(); var (shipment, totalWeight) = await shipmentViewModelService.PrepareShipment(order, orderItems, model); diff --git a/src/Web/Grand.Web.AdminShared/Mapper/PaymentSettingsProfile.cs b/src/Web/Grand.Web.AdminShared/Mapper/PaymentSettingsProfile.cs index 84d8e4c317..a8d3034513 100644 --- a/src/Web/Grand.Web.AdminShared/Mapper/PaymentSettingsProfile.cs +++ b/src/Web/Grand.Web.AdminShared/Mapper/PaymentSettingsProfile.cs @@ -10,7 +10,9 @@ public class PaymentSettingsProfile : Profile, IAutoMapperProfile public PaymentSettingsProfile() { CreateMap() - .ForMember(dest => dest.UserFields, mo => mo.Ignore()); + .ForMember(dest => dest.UserFields, mo => mo.Ignore()) + .ForMember(dest => dest.OrderApprovalPaymentMethodSystemNames, mo => mo.Ignore()) + .ForMember(dest => dest.AvailableOrderApprovalPaymentMethods, mo => mo.Ignore()); CreateMap() .ForMember(dest => dest.ActivePaymentProviderSystemNames, mo => mo.Ignore()); diff --git a/src/Web/Grand.Web.AdminShared/Models/Customers/CustomerModel.cs b/src/Web/Grand.Web.AdminShared/Models/Customers/CustomerModel.cs index dc43991213..5d330efd5d 100644 --- a/src/Web/Grand.Web.AdminShared/Models/Customers/CustomerModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Customers/CustomerModel.cs @@ -260,6 +260,13 @@ public class SubAccountModel : BaseEntityModel public string Email { get; set; } public string FullName { get; set; } public bool Active { get; set; } + + [GrandResourceDisplayName("Admin.Customers.Customers.SubAccounts.Role")] + public string Role { get; set; } + + //formatted in the primary store currency; empty when the account has no limit + [GrandResourceDisplayName("Admin.Customers.Customers.SubAccounts.OrderApprovalLimit")] + public string OrderApprovalLimit { get; set; } } public class LoyaltyPointsHistoryModel : BaseEntityModel diff --git a/src/Web/Grand.Web.AdminShared/Models/Payments/PaymentSettingsModel.cs b/src/Web/Grand.Web.AdminShared/Models/Payments/PaymentSettingsModel.cs index d8e67392be..e9f04d7e68 100644 --- a/src/Web/Grand.Web.AdminShared/Models/Payments/PaymentSettingsModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Payments/PaymentSettingsModel.cs @@ -1,5 +1,6 @@ using Grand.Infrastructure.ModelBinding; using Grand.Infrastructure.Models; +using Microsoft.AspNetCore.Mvc.Rendering; namespace Grand.Web.AdminShared.Models.Payments; @@ -19,4 +20,12 @@ public class PaymentSettingsModel : BaseModel [GrandResourceDisplayName("Admin.Configuration.Payment.Settings.SkipPaymentInfo")] public bool SkipPaymentInfo { get; set; } -} \ No newline at end of file + + /// + /// Payment methods offered for orders that wait for an approval; global, not per store + /// + [GrandResourceDisplayName("Admin.Configuration.Payment.Settings.OrderApprovalPaymentMethods")] + public IList OrderApprovalPaymentMethodSystemNames { get; set; } = new List(); + + public IList AvailableOrderApprovalPaymentMethods { get; set; } = new List(); +} diff --git a/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs index f9a4a61e30..ae568f0eb4 100644 --- a/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs @@ -1,5 +1,6 @@ using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Catalog.Prices; using Grand.Business.Core.Interfaces.Catalog.Tax; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Stores; @@ -52,6 +53,8 @@ public class CustomerViewModelService : ICustomerViewModelService private readonly IEnumTranslationService _enumTranslationService; private readonly CustomerConfig _customerConfig; private readonly IAdminDataScope _scope; + private readonly IPriceFormatter _priceFormatter; + private readonly ICurrencyService _currencyService; public CustomerViewModelService( ICustomerService customerService, @@ -74,8 +77,12 @@ public CustomerViewModelService( CommonSettings commonSettings, IEnumTranslationService enumTranslationService, CustomerConfig customerConfig, - IAdminDataScope scope) + IAdminDataScope scope, + IPriceFormatter priceFormatter, + ICurrencyService currencyService) { + _priceFormatter = priceFormatter; + _currencyService = currencyService; _customerConfig = customerConfig; _scope = scope; _customerService = customerService; @@ -401,6 +408,10 @@ private async Task PrepareSubAccounts(CustomerModel model, Customer customer) var storeId = await _scope.GetDefaultStoreIdAsync() ?? ""; var subAccounts = await _customerService.GetAllCustomers(ownerId: customer.Id, storeId: storeId, salesEmployeeId: _contextAccessor.WorkContext.CurrentCustomer.SeId); + //the limit is kept in the primary store currency; loaded once, and only when some row has a limit + var currency = subAccounts.Any(x => x.OrderApprovalLimit.HasValue) + ? await _currencyService.GetPrimaryStoreCurrency() + : null; foreach (var item in subAccounts) { if (!await _scope.HasAccess(item)) @@ -410,7 +421,11 @@ private async Task PrepareSubAccounts(CustomerModel model, Customer customer) Id = item.Id, Email = item.Email, FullName = item.GetFullName(), - Active = item.Active + Active = item.Active, + Role = _enumTranslationService.GetTranslationEnum(item.SubAccountRole), + OrderApprovalLimit = item.OrderApprovalLimit.HasValue + ? _priceFormatter.FormatPrice(item.OrderApprovalLimit.Value, currency) + : string.Empty }); } } diff --git a/src/Web/Grand.Web.AdminShared/Services/OrderViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/OrderViewModelService.cs index b2533e1bff..2a78c82466 100644 --- a/src/Web/Grand.Web.AdminShared/Services/OrderViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/OrderViewModelService.cs @@ -324,9 +324,11 @@ public virtual async Task PrepareOrderDetailsModel(OrderModel model, Order order model.OrderGuid = order.OrderGuid; var status = await _orderStatusService.GetAll(); + //Awaiting approval is set only by the checkout, so it is not offered as a target of a manual change if (_scope.DefaultVendorId is null) - model.OrderStatuses = - status.Select(x => new SelectListItem { Value = x.StatusId.ToString(), Text = x.Name }).ToList(); + model.OrderStatuses = status + .Where(x => x.StatusId != (int)OrderStatusSystem.AwaitingApproval || x.StatusId == order.OrderStatusId) + .Select(x => new SelectListItem { Value = x.StatusId.ToString(), Text = x.Name }).ToList(); model.OrderStatus = status.FirstOrDefault(x => x.StatusId == order.OrderStatusId)?.Name; //cancelling runs the order's side effects (stock, gift vouchers) for the whole order, so it is //offered to the panels that own the order, not to a vendor who sees only his own items @@ -703,6 +705,10 @@ await _addressAttributeParser.FormatAttributes(_contextAccessor.WorkContext.Work model.CanAddNewShipments = true; } + + //nothing ships before the approver decides + if (order.OrderStatusId == (int)OrderStatusSystem.AwaitingApproval) + model.CanAddNewShipments = false; } #endregion 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 b1b8aa7948..90829e3709 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 @@ -115,6 +115,8 @@ @Loc["Admin.Customers.Customers.Fields.Email"] @Loc["Admin.Customers.Customers.Fields.FullName"] @Loc["Admin.Customers.Customers.Fields.Active"] + @Loc["Admin.Customers.Customers.SubAccounts.Role"] + @Loc["Admin.Customers.Customers.SubAccounts.OrderApprovalLimit"] @@ -124,6 +126,8 @@ @subAccount.Email @subAccount.FullName @(subAccount.Active ? Loc["Admin.Common.Yes"] : Loc["Admin.Common.No"]) + @subAccount.Role + @subAccount.OrderApprovalLimit } diff --git a/src/Web/Grand.Web.Models/Checkout/CheckoutConfirmModel.cs b/src/Web/Grand.Web.Models/Checkout/CheckoutConfirmModel.cs index 31ddfc1310..75c4ab94ed 100644 --- a/src/Web/Grand.Web.Models/Checkout/CheckoutConfirmModel.cs +++ b/src/Web/Grand.Web.Models/Checkout/CheckoutConfirmModel.cs @@ -8,6 +8,21 @@ public class CheckoutConfirmModel : BaseModel public bool TermsOfServiceOnOrderConfirmPage { get; set; } public string MinOrderTotalWarning { get; set; } + /// + /// The order exceeds the buyer's order limit: it is placed at once and waits for an approver's decision + /// + public bool OrderApprovalRequired { get; set; } + + /// + /// Tells the buyer the order goes to the company for approval, with the limit formatted + /// + public string OrderApprovalMessage { get; set; } + + /// + /// Label of the confirm button when the order needs an approval + /// + public string OrderApprovalButtonText { get; set; } + public IList Warnings { get; set; } = new List(); public OrderReviewDataModel OrderReviewData { get; set; } = new(); diff --git a/src/Web/Grand.Web.Models/Checkout/CheckoutPaymentMethodModel.cs b/src/Web/Grand.Web.Models/Checkout/CheckoutPaymentMethodModel.cs index 8bb6cf8085..43af68fea8 100644 --- a/src/Web/Grand.Web.Models/Checkout/CheckoutPaymentMethodModel.cs +++ b/src/Web/Grand.Web.Models/Checkout/CheckoutPaymentMethodModel.cs @@ -13,6 +13,11 @@ public class CheckoutPaymentMethodModel : BaseModel public bool UseLoyaltyPoints { get; set; } public string PaymentMethod { get; set; } + /// + /// Set when the order needs an approval and no payment method is allowed for such orders + /// + public string NoPaymentMethodMessage { get; set; } + #region Nested classes public class PaymentMethodModel : BaseModel 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/Customer/OrderApprovalLimitText.cs b/src/Web/Grand.Web.Models/Customer/OrderApprovalLimitText.cs new file mode 100644 index 0000000000..f61e39e823 --- /dev/null +++ b/src/Web/Grand.Web.Models/Customer/OrderApprovalLimitText.cs @@ -0,0 +1,38 @@ +using System.Globalization; + +namespace Grand.Web.Models.Customer; + +/// +/// The order approval limit as a shopper types and reads it: text in the current culture. A native number +/// input posts a dot decimal, which a comma-decimal culture rejects or reads as a thousands separator +/// (de-DE turns "250.5" into 2505), so the limit travels as text and is parsed here, once, the way the +/// storefront parses its other money fields. +/// +public static class OrderApprovalLimitText +{ + /// + /// Parses the text with the current culture. Empty means no limit (null); returns false for text that is + /// not a finite, non-negative number. + /// + public static bool TryParse(string text, out double? limit) + { + limit = null; + if (string.IsNullOrWhiteSpace(text)) + return true; + + if (!double.TryParse(text.Trim(), NumberStyles.Float | NumberStyles.AllowThousands, + CultureInfo.CurrentCulture, out var value) || !double.IsFinite(value) || value < 0) + return false; + + limit = value; + return true; + } + + /// + /// Formats the limit with the current culture, without a currency symbol or digit grouping; empty for none + /// + public static string Format(double? limit) + { + return limit.HasValue ? limit.Value.ToString(CultureInfo.CurrentCulture) : string.Empty; + } +} diff --git a/src/Web/Grand.Web.Models/Customer/SubAccountEditModel.cs b/src/Web/Grand.Web.Models/Customer/SubAccountEditModel.cs index 106e5dcbfc..7ed9b9b986 100644 --- a/src/Web/Grand.Web.Models/Customer/SubAccountEditModel.cs +++ b/src/Web/Grand.Web.Models/Customer/SubAccountEditModel.cs @@ -1,4 +1,5 @@ -using Grand.Infrastructure.Models; +using Grand.Domain.Customers; +using Grand.Infrastructure.Models; using Grand.SharedKernel; using System.ComponentModel.DataAnnotations; @@ -18,5 +19,16 @@ public class SubAccountEditModel : BaseEntityModel public bool Active { get; set; } + /// + /// Purchasing role of the sub-account + /// + public SubAccountRole Role { get; set; } + + /// + /// Order total above which a buyer's order needs an approval, in the primary store currency, as text in the + /// current culture (see ); empty means no limit. An approver has none. + /// + public string OrderApprovalLimit { get; set; } + [DataType(DataType.Password)] public string Password { get; set; } } \ 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..8df5967b69 --- /dev/null +++ b/src/Web/Grand.Web.Models/Orders/OrderApprovalListModel.cs @@ -0,0 +1,43 @@ +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; } + + /// + /// The placed order awaiting the decision + /// + public string OrderId { get; set; } + + public int OrderNumber { get; set; } + + /// + /// Gets or sets a value indicating whether the viewer may open the order's details page + /// + public bool CanViewOrder { 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..510e4c2042 --- /dev/null +++ b/src/Web/Grand.Web.Models/Orders/OrderApprovalModel.cs @@ -0,0 +1,60 @@ +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; } + + /// + /// The placed order awaiting the decision + /// + public string OrderId { get; set; } + + public int OrderNumber { get; set; } + + /// + /// Gets or sets a value indicating whether the viewer may open the order's details page + /// + public bool CanViewOrder { 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..511289b417 100644 --- a/src/Web/Grand.Web.Models/ShoppingCart/ShoppingCartModel.cs +++ b/src/Web/Grand.Web.Models/ShoppingCart/ShoppingCartModel.cs @@ -19,6 +19,7 @@ public class ShoppingCartModel : BaseModel public IList Warnings { get; set; } = new List(); public string MinOrderSubtotalWarning { get; set; } + public bool ShowCheckoutAsGuestButton { get; set; } public bool IsGuest { get; set; } public bool TermsOfServiceOnShoppingCartPage { get; set; } diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index 36c651d6fc..b513afc5ac 100644 Binary files a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml and b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml differ diff --git a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml index e7c3ca8f76..cf118788a9 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 @@ -303,4 +303,175 @@ Subaccounts + + Order approvals + + + Order approval limit + + + Orders above this amount are held until an approver has approved them. Leave empty for no limit. Approvers have no limit. + + + Enter an amount of zero or more. + + + Role + + + Choose a valid role. + + + Payment methods for orders awaiting approval + + + Order approval limit + + + Role + + + This order awaits approval: approve or reject it in the storefront, or cancel it. + + + The status Awaiting approval cannot be set by hand. The checkout sets it for an order that needs an approval. + + + This order awaits approval. Shipments can be added after it has been approved. + + + No payment method is available for orders that need approval. Please contact the store. + + + This payment method cannot be used for an order that needs approval. Choose another one. + + + Send for approval + + + This order is above your limit of {0}. It is placed now, but not processed until an approver has approved it. + + + Approver + + + Buyer + + + Approved + + + Cancelled + + + Pending + + + Rejected + + + Withdrawn + + + Approve + + + Requested by + + + Comment + + + Requested on + + + The order has been approved. + + + This request can no longer be decided. + + + The order has been rejected. + + + Decided by + + + Decided on + + + Order approval + + + Discount + + + Price + + + Product + + + Quantity + + + SKU + + + Total + + + Items + + + Total + + + There are no order approval requests. + + + Order + + + Product + + + Quantity + + + Reject + + + Review + + + Shipping + + + SKU + + + Status + + + Subtotal + + + Tax + + + Total + + + Price + + + Withdraw + + + This request can no longer be withdrawn. + + + The request has been withdrawn. + diff --git a/src/Web/Grand.Web/Commands/Handler/Customers/SubAccountEditCommandHandler.cs b/src/Web/Grand.Web/Commands/Handler/Customers/SubAccountEditCommandHandler.cs index a9a7564637..50ea108610 100644 --- a/src/Web/Grand.Web/Commands/Handler/Customers/SubAccountEditCommandHandler.cs +++ b/src/Web/Grand.Web/Commands/Handler/Customers/SubAccountEditCommandHandler.cs @@ -2,6 +2,7 @@ using Grand.Business.Core.Utilities.Customers; using Grand.Domain.Customers; using Grand.Web.Commands.Models.Customers; +using Grand.Web.Models.Customer; using Grand.Mediator; namespace Grand.Web.Commands.Handler.Customers; @@ -60,6 +61,17 @@ await _customerManagerService.ChangePassword( await _customerService.UpdateUserField(customer, SystemCustomerFieldNames.FirstName, request.EditModel.FirstName); await _customerService.UpdateUserField(customer, SystemCustomerFieldNames.LastName, request.EditModel.LastName); + //update role and order limit - only after the ownership check above. An approver orders without a limit, + //so whatever limit came with the request is dropped + var role = request.EditModel.Role; + //the text was checked by the validator; unparseable text from a caller that skipped it means no limit + OrderApprovalLimitText.TryParse(request.EditModel.OrderApprovalLimit, out var typedLimit); + var limit = role == SubAccountRole.Approver ? null : typedLimit; + customer.SubAccountRole = role; + customer.OrderApprovalLimit = limit; + await _customerService.UpdateCustomerField(customer, x => x.SubAccountRole, role); + await _customerService.UpdateCustomerField(customer, x => x.OrderApprovalLimit, limit); + return true; } } \ No newline at end of file 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..62db0d1e48 --- /dev/null +++ b/src/Web/Grand.Web/Commands/Handler/Orders/DecideOrderApprovalCommandHandler.cs @@ -0,0 +1,151 @@ +using Grand.Business.Core.Commands.Checkout.Orders; +using Grand.Business.Core.Commands.Messages.Common; +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.Customers; +using Grand.Domain.Orders; +using Grand.Domain.Payments; +using Grand.Mediator; +using Grand.Web.Commands.Models.Orders; +using Microsoft.Extensions.Logging; + +namespace Grand.Web.Commands.Handler.Orders; + +public class DecideOrderApprovalCommandHandler : IRequestHandler +{ + private const int MaxCommentLength = 1000; + + private readonly ICustomerService _customerService; + private readonly ILogger _logger; + private readonly IMediator _mediator; + private readonly ICustomerMessageProviderService _messageProviderService; + private readonly IOrderApprovalService _orderApprovalService; + private readonly IOrderService _orderService; + + public DecideOrderApprovalCommandHandler(IOrderApprovalService orderApprovalService, + ICustomerService customerService, ICustomerMessageProviderService messageProviderService, + IOrderService orderService, IMediator mediator, ILogger logger) + { + _orderApprovalService = orderApprovalService; + _customerService = customerService; + _messageProviderService = messageProviderService; + _orderService = orderService; + _mediator = mediator; + _logger = logger; + } + + 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, an order that no longer + // awaits the decision 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; + + // nobody decides their own order, also not a buyer made an approver after placing it + if (approval.CustomerId == request.Approver.Id) + return false; + + var order = await _orderService.GetOrderById(approval.OrderId); + if (order == null || order.OrderStatusId != (int)OrderStatusSystem.AwaitingApproval) + return false; + + var buyer = await _customerService.GetCustomerById(approval.CustomerId); + + // the order moves first: when that fails the request stays Pending and can be decided again + try + { + if (request.Approve) + // the order was placed in the awaiting status; nobody is told about the status change itself + await _mediator.Send(new SetOrderStatusCommand { + Order = order, Os = OrderStatusSystem.Pending, NotifyCustomer = false, NotifyStoreOwner = false + }, cancellationToken); + else + // the customer is not told about the cancellation: the decision e-mail below says it + await _mediator.Send(new CancelOrderCommand { Order = order, NotifyCustomer = false }, + cancellationToken); + } + catch (Exception e) + { + _logger.LogError(e, "Order approval {Id}: the order could not be moved, the request stays pending", + approval.Id); + 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); + + // from here the order is an ordinary one: a later failure must not undo or abort the decision + if (request.Approve) + await ContinueApproved(order, buyer, request, cancellationToken); + + await Guard(approval.Id, "decision e-mail", async () => + { + if (buyer != null && !string.IsNullOrWhiteSpace(buyer.Email)) + // the buyer reads it: in the language the order was placed in, not the approver's + await _messageProviderService.SendOrderApprovalDecidedMessage(approval, buyer, request.Store, + string.IsNullOrEmpty(order.CustomerLanguageId) ? request.Language?.Id : order.CustomerLanguageId, + request.Host); + }); + + return true; + } + + private async Task ContinueApproved(Order order, Customer buyer, DecideOrderApprovalCommand request, + CancellationToken cancellationToken) + { + await Guard(order.Id, "order note", () => _orderService.InsertOrderNote(new OrderNote { + Note = "Order approved", DisplayToCustomer = false, OrderId = order.Id + })); + + await Guard(order.Id, "order status check", + () => _mediator.Send(new CheckOrderStatusCommand { Order = order }, cancellationToken)); + + // the customer got the "order placed" e-mail at placement; the store owner and the vendors hear now + await Guard(order.Id, "store owner and vendor e-mails", () => _mediator.Send(new OrderNotificationCommand { + Order = order, Customer = buyer, Host = request.Host, NotifyCustomer = false, + NotifyStoreOwnerAndVendors = true, AddOrderPlacedNote = false + }, cancellationToken)); + + // the "paid" processing was held back at placement + if (order.PaymentStatusId == PaymentStatus.Paid) + await Guard(order.Id, "paid processing", + () => _mediator.Send(new ProcessOrderPaidCommand { Order = order }, cancellationToken)); + } + + /// + /// A failed follow-up step must neither undo nor abort a decision that is already saved + /// + private async Task Guard(string id, string step, Func run) + { + try + { + await run(); + } + catch (Exception e) + { + _logger.LogError(e, "Order approval {Id}: {Step} failed after the decision was saved", id, step); + } + } + + 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/WithdrawOrderApprovalCommandHandler.cs b/src/Web/Grand.Web/Commands/Handler/Orders/WithdrawOrderApprovalCommandHandler.cs new file mode 100644 index 0000000000..6a85b6bf73 --- /dev/null +++ b/src/Web/Grand.Web/Commands/Handler/Orders/WithdrawOrderApprovalCommandHandler.cs @@ -0,0 +1,61 @@ +using Grand.Business.Core.Commands.Checkout.Orders; +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; +using Microsoft.Extensions.Logging; + +namespace Grand.Web.Commands.Handler.Orders; + +public class WithdrawOrderApprovalCommandHandler : IRequestHandler +{ + private readonly ILogger _logger; + private readonly IMediator _mediator; + private readonly IOrderApprovalService _orderApprovalService; + private readonly IOrderService _orderService; + + public WithdrawOrderApprovalCommandHandler(IOrderApprovalService orderApprovalService, + IOrderService orderService, IMediator mediator, ILogger logger) + { + _orderApprovalService = orderApprovalService; + _orderService = orderService; + _mediator = mediator; + _logger = logger; + } + + 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 + if (approval == null || approval.CustomerId != request.Customer.Id || approval.StoreId != request.Store.Id || + approval.Status != OrderApprovalStatus.Pending) + return false; + + var order = await _orderService.GetOrderById(approval.OrderId); + if (order == null || order.OrderStatusId != (int)OrderStatusSystem.AwaitingApproval) + return false; + + // the order is cancelled first: when that fails the request stays pending and can be withdrawn again. + // The buyer withdrew it themselves, so no cancellation e-mail + try + { + await _mediator.Send(new CancelOrderCommand { Order = order, NotifyCustomer = false }, cancellationToken); + } + catch (Exception e) + { + _logger.LogError(e, "Order approval {Id}: the order could not be cancelled, the request stays pending", + approval.Id); + 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/WithdrawOrderApprovalCommand.cs b/src/Web/Grand.Web/Commands/Models/Orders/WithdrawOrderApprovalCommand.cs new file mode 100644 index 0000000000..28101850ae --- /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 order approval and cancels the order that waits for it +/// +public class WithdrawOrderApprovalCommand : IRequest +{ + public Customer Customer { get; set; } + public Domain.Stores.Store Store { get; set; } + public string ApprovalId { get; set; } +} diff --git a/src/Web/Grand.Web/Controllers/CheckoutController.cs b/src/Web/Grand.Web/Controllers/CheckoutController.cs index 2abede8661..9f5ecd96c8 100644 --- a/src/Web/Grand.Web/Controllers/CheckoutController.cs +++ b/src/Web/Grand.Web/Controllers/CheckoutController.cs @@ -53,6 +53,7 @@ public CheckoutController( IMediator mediator, IProductService productService, IShoppingCartValidator shoppingCartValidator, + IOrderApprovalService orderApprovalService, OrderSettings orderSettings, OidcLoginConfig oidcLoginConfig, LoyaltyPointsSettings loyaltyPointsSettings, @@ -74,6 +75,7 @@ public CheckoutController( _mediator = mediator; _productService = productService; _shoppingCartValidator = shoppingCartValidator; + _orderApprovalService = orderApprovalService; _orderSettings = orderSettings; _oidcLoginConfig = oidcLoginConfig; _loyaltyPointsSettings = loyaltyPointsSettings; @@ -575,7 +577,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 { @@ -587,6 +589,11 @@ await _customerService.UpdateUserField(_contextAccessor.WorkContext.Curr }); } + //an order over the buyer's limit waits for an approval and may only use the payment methods the admin chose + var approval = await _orderApprovalService.RequiresApproval(cart, _contextAccessor.CalculationContext()); + if (approval.Required && !_orderApprovalService.IsPaymentMethodAllowed(model.PaymentMethod)) + throw new Exception(_translationService.GetResource("Checkout.OrderApproval.PaymentMethodNotAllowed")); + var paymentMethodInst = _paymentService.LoadPaymentMethodBySystemName(model.PaymentMethod); if (paymentMethodInst == null || !paymentMethodInst.IsPaymentMethodActive(_paymentSettings) || @@ -642,7 +649,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 +762,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) @@ -837,6 +844,7 @@ public virtual async Task CompleteRedirectionPayment(string payme private readonly IMediator _mediator; private readonly IProductService _productService; private readonly IShoppingCartValidator _shoppingCartValidator; + private readonly IOrderApprovalService _orderApprovalService; private readonly OrderSettings _orderSettings; private readonly OidcLoginConfig _oidcLoginConfig; private readonly LoyaltyPointsSettings _loyaltyPointsSettings; @@ -985,7 +993,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 +1014,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..44cb4764fc --- /dev/null +++ b/src/Web/Grand.Web/Controllers/OrderApprovalController.cs @@ -0,0 +1,136 @@ +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's order waits for an approver's +/// decision. 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); + } + + [HttpPost] + [CustomerGroupAuthorize(SystemCustomerGroupNames.Registered)] + public virtual async Task Decide(string id, bool? approve, string comment) + { + if (FeatureOff) + return RedirectToRoute("CustomerInfo"); + + // the decision must be explicit: a missing value is never read as a rejection or an approval + if (approve == null) + { + Error(_translationService.GetResource("OrderApproval.Decide.Refused")); + return RedirectToRoute("CustomerOrderApprovals"); + } + + var decided = await _mediator.Send(new DecideOrderApprovalCommand { + Approver = _contextAccessor.WorkContext.CurrentCustomer, + Store = _contextAccessor.StoreContext.CurrentStore, + ApprovalId = id, + Approve = approve.Value, + Comment = comment, + Language = _contextAccessor.WorkContext.WorkingLanguage, + Host = _contextAccessor.StoreContext.CurrentHost + }); + + if (decided) + Success(_translationService.GetResource(approve.Value + ? "OrderApproval.Decide.Approved" + : "OrderApproval.Decide.Rejected")); + else + Error(_translationService.GetResource("OrderApproval.Decide.Refused")); + + return RedirectToRoute("CustomerOrderApprovals"); + } + + /// Approval identifier + [HttpPost] + [CustomerGroupAuthorize(SystemCustomerGroupNames.Registered)] + public virtual async Task Withdraw(string id) + { + 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("CustomerOrderApprovals"); + } +} diff --git a/src/Web/Grand.Web/Endpoints/EndpointProvider.cs b/src/Web/Grand.Web/Endpoints/EndpointProvider.cs index 36524a3b05..1d2a23d763 100644 --- a/src/Web/Grand.Web/Endpoints/EndpointProvider.cs +++ b/src/Web/Grand.Web/Endpoints/EndpointProvider.cs @@ -160,6 +160,22 @@ 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("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..c3a8a850a3 100644 --- a/src/Web/Grand.Web/Features/Handlers/Checkout/GetConfirmOrderHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Checkout/GetConfirmOrderHandler.cs @@ -1,10 +1,15 @@ -using Grand.Business.Core.Interfaces.Checkout.Payments; +using Grand.Business.Core.Interfaces.Catalog.Prices; +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.Business.Core.Utilities.Checkout; 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,6 +21,10 @@ namespace Grand.Web.Features.Handlers.Checkout; public class GetConfirmOrderHandler : IRequestHandler { private readonly ICountryService _countryService; + private readonly ICurrencyService _currencyService; + private readonly IOrderApprovalService _orderApprovalService; + private readonly IPriceFormatter _priceFormatter; + private readonly IRequestTranslationService _translationService; private readonly IMediator _mediator; private readonly OrderSettings _orderSettings; private readonly IPaymentService _paymentService; @@ -25,13 +34,20 @@ public GetConfirmOrderHandler( ICountryService countryService, IPaymentService paymentService, IMediator mediator, + IOrderApprovalService orderApprovalService, + ICurrencyService currencyService, + IPriceFormatter priceFormatter, + IRequestTranslationService translationService, OrderSettings orderSettings, ShippingSettings shippingSettings) - { _countryService = countryService; _paymentService = paymentService; _mediator = mediator; + _orderApprovalService = orderApprovalService; + _currencyService = currencyService; + _priceFormatter = priceFormatter; + _translationService = translationService; _orderSettings = orderSettings; _shippingSettings = shippingSettings; } @@ -43,9 +59,35 @@ public async Task Handle(GetConfirmOrder request, Cancella TermsOfServiceOnOrderConfirmPage = _orderSettings.TermsOfServiceOnOrderConfirmPage }; await PrepareOrderReviewData(model, request); + await PrepareOrderApproval(model, request); return model; } + /// + /// An order over the buyer's limit is placed at once and waits for an approval; the buyer is told so + /// + private async Task PrepareOrderApproval(CheckoutConfirmModel model, GetConfirmOrder request) + { + var state = await _orderApprovalService.RequiresApproval(request.Cart, + new CalculationContext(request.Customer, request.Store, request.Currency, request.TaxDisplayType)); + if (!state.Required) + return; + + model.OrderApprovalRequired = true; + var limit = await _currencyService.ConvertFromPrimaryStoreCurrency(state.Limit ?? 0, request.Currency); + var message = _translationService.GetResource("Checkout.OrderApproval.WillBeSent"); + try + { + model.OrderApprovalMessage = string.Format(message, _priceFormatter.FormatPrice(limit, request.Currency)); + } + catch (FormatException) + { + //the text is editable by an operator: a broken placeholder must not break the confirm page + model.OrderApprovalMessage = message; + } + model.OrderApprovalButtonText = _translationService.GetResource("Checkout.OrderApproval.SendButton"); + } + private async Task PrepareOrderReviewData(CheckoutConfirmModel model, GetConfirmOrder request) { //billing info diff --git a/src/Web/Grand.Web/Features/Handlers/Checkout/GetPaymentMethodHandler.cs b/src/Web/Grand.Web/Features/Handlers/Checkout/GetPaymentMethodHandler.cs index 8a6c8e95ed..e7f2d32d66 100644 --- a/src/Web/Grand.Web/Features/Handlers/Checkout/GetPaymentMethodHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Checkout/GetPaymentMethodHandler.cs @@ -10,6 +10,7 @@ using Grand.Domain.Orders; using Grand.Domain.Payments; using Grand.Domain.Tax; +using Grand.Web.Common.Localization; using Grand.Web.Features.Models.Checkout; using Grand.Web.Models.Checkout; using Grand.Mediator; @@ -28,6 +29,8 @@ public class GetPaymentMethodHandler : IRequestHandler _orderApprovalService.IsPaymentMethodAllowed(pm.SystemName)).ToList(); + if (availablepaymentMethods.Count == 0) + model.NoPaymentMethodMessage = _translationService.GetResource("Checkout.OrderApproval.NoPaymentMethod"); + } + foreach (var pm in availablepaymentMethods) { var pmModel = new CheckoutPaymentMethodModel.PaymentMethodModel { 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/Customers/GetSubAccountHandler.cs b/src/Web/Grand.Web/Features/Handlers/Customers/GetSubAccountHandler.cs index 45e11bf93f..2ef86903d2 100644 --- a/src/Web/Grand.Web/Features/Handlers/Customers/GetSubAccountHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Customers/GetSubAccountHandler.cs @@ -29,6 +29,8 @@ public async Task Handle(GetSubAccount request, Cancellatio Id = subaccount.Id, Email = subaccount.Email, Active = subaccount.Active, + Role = subaccount.SubAccountRole, + OrderApprovalLimit = OrderApprovalLimitText.Format(subaccount.OrderApprovalLimit), FirstName = subaccount.GetUserFieldFromEntity(SystemCustomerFieldNames.FirstName), LastName = subaccount.GetUserFieldFromEntity(SystemCustomerFieldNames.LastName) }; 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..38673a8701 --- /dev/null +++ b/src/Web/Grand.Web/Features/Handlers/Orders/GetOrderApprovalHandler.cs @@ -0,0 +1,102 @@ +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; + private readonly IOrderService _orderService; + + public GetOrderApprovalHandler(IOrderApprovalService orderApprovalService, ICustomerService customerService, + IOrderService orderService, IDateTimeService dateTimeService, IEnumTranslationService enumTranslationService) + { + _orderApprovalService = orderApprovalService; + _customerService = customerService; + _orderService = orderService; + _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, + // a buyer made an approver after placing the order still does not decide it + CanDecide = isApprover && !isBuyer && approval.Status == OrderApprovalStatus.Pending, + CanWithdraw = isBuyer && approval.Status == OrderApprovalStatus.Pending + }; + + 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 order = string.IsNullOrEmpty(approval.OrderId) ? null : await _orderService.GetOrderById(approval.OrderId); + if (order != null) + { + model.OrderId = order.Id; + model.OrderNumber = order.OrderNumber; + model.CanViewOrder = OrderApprovalRules.CanOpenOrder(request.Customer, order); + } + + 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..5efdb54739 --- /dev/null +++ b/src/Web/Grand.Web/Features/Handlers/Orders/GetOrderApprovalsHandler.cs @@ -0,0 +1,88 @@ +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; + private readonly IOrderService _orderService; + + public GetOrderApprovalsHandler(IOrderApprovalService orderApprovalService, ICustomerService customerService, + IOrderService orderService, IDateTimeService dateTimeService, IEnumTranslationService enumTranslationService) + { + _orderApprovalService = orderApprovalService; + _customerService = customerService; + _orderService = orderService; + _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; + + var orders = new Dictionary(); + var orderIds = approvals.Where(x => !string.IsNullOrEmpty(x.OrderId)).Select(x => x.OrderId).Distinct() + .ToArray(); + if (orderIds.Length > 0) + foreach (var order in await _orderService.GetOrdersByIds(orderIds)) + orders[order.Id] = order; + + foreach (var approval in approvals) + { + var order = approval.OrderId != null ? orders.GetValueOrDefault(approval.OrderId) : null; + 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, + OrderId = order?.Id, + OrderNumber = order?.OrderNumber ?? 0, + CanViewOrder = order != null && OrderApprovalRules.CanOpenOrder(request.Customer, order), + 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/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/Validators/Customer/SubAccountEditValidator.cs b/src/Web/Grand.Web/Validators/Customer/SubAccountEditValidator.cs index 6cce1b35db..fe2a66aa53 100644 --- a/src/Web/Grand.Web/Validators/Customer/SubAccountEditValidator.cs +++ b/src/Web/Grand.Web/Validators/Customer/SubAccountEditValidator.cs @@ -39,6 +39,14 @@ public SubAccountEditValidator( .WithMessage(string.Format(translationService.GetResource("Account.Fields.Password.Validation"))) .When(subAccount => !string.IsNullOrEmpty(subAccount.Password)); + RuleFor(x => x.Role).IsInEnum() + .WithMessage(translationService.GetResource("Account.SubAccounts.Fields.Role.Invalid")); + + //empty means no limit; anything else must read as a non-negative number in the current culture + RuleFor(x => x.OrderApprovalLimit) + .Must(text => OrderApprovalLimitText.TryParse(text, out _)) + .WithMessage(translationService.GetResource("Account.SubAccounts.Fields.OrderApprovalLimit.Invalid")); + RuleFor(x => x).CustomAsync(async (x, context, _) => { var customer = await customerService.GetCustomerById(x.Id); diff --git a/src/Web/Grand.Web/Views/Account/Partials/EditSubAccount.cshtml b/src/Web/Grand.Web/Views/Account/Partials/EditSubAccount.cshtml index 14761c87af..710309bcc0 100644 --- a/src/Web/Grand.Web/Views/Account/Partials/EditSubAccount.cshtml +++ b/src/Web/Grand.Web/Views/Account/Partials/EditSubAccount.cshtml @@ -1,5 +1,12 @@ -@model SubAccountEditModel +@using Grand.Business.Core.Interfaces.Common.Directory +@model SubAccountEditModel @inject CustomerSettings customerSettings +@inject IEnumTranslationService EnumTranslationService +@inject ICurrencyService CurrencyService +@{ + var approverRole = (int)SubAccountRole.Approver; + var primaryCurrency = await CurrencyService.GetPrimaryStoreCurrency(); +}
@@ -39,6 +46,18 @@
+
+ + + +
+ @* An approver orders without a limit, so the field goes away for that role; the server drops it anyway. *@ +
+ + + @Loc["Account.SubAccounts.Fields.OrderApprovalLimit.Hint"] + +
- \ No newline at end of file + \ No newline at end of file diff --git a/src/Web/Grand.Web/Views/Checkout/Partials/ConfirmOrder.cshtml b/src/Web/Grand.Web/Views/Checkout/Partials/ConfirmOrder.cshtml index dfa237a3a7..21a5c08fd3 100644 --- a/src/Web/Grand.Web/Views/Checkout/Partials/ConfirmOrder.cshtml +++ b/src/Web/Grand.Web/Views/Checkout/Partials/ConfirmOrder.cshtml @@ -11,6 +11,9 @@ {{vmorder.MinOrderTotalWarning}} +
+ {{vmorder.OrderApprovalMessage}} +
\ No newline at end of file diff --git a/src/Web/Grand.Web/Views/Checkout/Partials/PaymentMethods.cshtml b/src/Web/Grand.Web/Views/Checkout/Partials/PaymentMethods.cshtml index 00ab78447d..f2eedebb49 100644 --- a/src/Web/Grand.Web/Views/Checkout/Partials/PaymentMethods.cshtml +++ b/src/Web/Grand.Web/Views/Checkout/Partials/PaymentMethods.cshtml @@ -11,6 +11,9 @@ +
+ {{vmorder.NoPaymentMethodMessage}} +
\ No newline at end of file diff --git a/src/Web/Grand.Web/wwwroot/theme/script/public.checkout.js b/src/Web/Grand.Web/wwwroot/theme/script/public.checkout.js index 6a6f4cf07d..48d644794d 100644 --- a/src/Web/Grand.Web/wwwroot/theme/script/public.checkout.js +++ b/src/Web/Grand.Web/wwwroot/theme/script/public.checkout.js @@ -36,6 +36,7 @@ var vmorder = Vue.registerViewModel('vmorder', new Vue({ // payment methods DisplayLoyaltyPoints: null, PaymentMethods: null, + NoPaymentMethodMessage: null, LoyaltyPointsAmount: null, LoyaltyPointsBalance: null, LoyaltyPointsEnoughToPayForOrder: null, @@ -46,6 +47,9 @@ var vmorder = Vue.registerViewModel('vmorder', new Vue({ // confirm order OrderReviewData: null, MinOrderTotalWarning: null, + OrderApprovalRequired: false, + OrderApprovalMessage: null, + OrderApprovalButtonText: null, TermsOfServiceOnOrderConfirmPage: null, ConfirmWarnings: null, // terms of service @@ -235,6 +239,7 @@ var vmorder = Vue.registerViewModel('vmorder', new Vue({ var model = response.data.update_section.model; vmorder.DisplayLoyaltyPoints = model.DisplayLoyaltyPoints; vmorder.PaymentMethods = model.PaymentMethods; + vmorder.NoPaymentMethodMessage = model.NoPaymentMethodMessage; vmorder.LoyaltyPointsAmount = model.LoyaltyPointsAmount; vmorder.LoyaltyPointsBalance = model.LoyaltyPointsBalance; vmorder.LoyaltyPointsEnoughToPayForOrder = model.LoyaltyPointsEnoughToPayForOrder; @@ -278,6 +283,9 @@ var vmorder = Vue.registerViewModel('vmorder', new Vue({ if (response.data.goto_section == "confirm_order") { var model = response.data.update_section.model; vmorder.MinOrderTotalWarning = model.MinOrderTotalWarning; + vmorder.OrderApprovalRequired = model.OrderApprovalRequired; + vmorder.OrderApprovalMessage = model.OrderApprovalMessage; + vmorder.OrderApprovalButtonText = model.OrderApprovalButtonText; vmorder.ConfirmWarnings = model.Warnings; vmorder.OrderReviewData = model.OrderReviewData; vmorder.Confirm = true;