diff --git a/.ai/glossary/sales.md b/.ai/glossary/sales.md index aef2f69a6..371b9774f 100644 --- a/.ai/glossary/sales.md +++ b/.ai/glossary/sales.md @@ -25,6 +25,9 @@ There is no `Cart` entity. A cart is the set of `ShoppingCartItem` rows on a `Cu | **Order note** | `OrderNote` — operator or system annotation, optionally customer-visible | | **Order tag** | `OrderTag` — operator-defined label for filtering | | **Order tax** | `OrderTax` — the tax breakdown as computed at placement | +| **Order placement rule** | `IOrderPlacementRuleProvider` — a provider that allows an order being placed, requires an approval for it, or blocks it | +| **Order approval** | `OrderApproval` — the request to decide an order placed in the `AwaitingApproval` status | +| **Store-decided approval** | an `OrderApproval` with `Decider` set to `Store`: decided in the Admin or Store panel, with no `CompanyOwnerId`. It is the buyer without a company, a rule's `RequireStoreApproval`, or a company with nobody to ask. A company's request is decided on the storefront | `OrderItem` stores its own prices. Never recompute an order's totals from current product prices — the sold price is the record. diff --git a/.ai/skills/plugin-module.md b/.ai/skills/plugin-module.md index da81d1884..7029c61f4 100644 --- a/.ai/skills/plugin-module.md +++ b/.ai/skills/plugin-module.md @@ -56,6 +56,17 @@ Do not use this skill as the primary review for payment correctness, tenant isol 5. Prefer small provider methods that delegate complex behavior to plugin services. 6. Prefer adding a new reference file only when a new extension type appears in the repository. +### Order Placement Rules +A plugin takes part in order placement by implementing `IOrderPlacementRuleProvider` and registering it in its `StartupApplication` (`services.AddScoped()`). `Evaluate` returns `OrderPlacementRuleResult.Allow()`, `.RequireApproval(reason)`, `.RequireStoreApproval(reason)` or `.Block(reason)`. Any number of rules can be active: any block blocks the order, otherwise any required approval requires it, and an `Allow` never cancels another rule's decision. `RequireApproval` lets a sub-account's company decide; use `RequireStoreApproval` for a control the buyer's company must not release. Pass `Evaluate`'s `CancellationToken` to any external call. + +1. A rule runs several times per checkout, also on a page view, so it must be fast and have no side effects. +2. `PaymentMethodSystemName` and `GetTotal()` can be null early in checkout, and the rule must decide sensibly then. The call made when the order is placed is the authoritative one. +3. The reason is plain text shown to the buyer and to whoever decides, so nothing confidential. Write it in `context.Language`. +4. A rule that throws blocks the order and is logged, so a faulty rule can stop sales in its stores. Use `LimitedToStores` and `LimitedToGroups` to narrow where it runs. +5. Do not inject `IOrderPlacementService`. Use `context.GetTotal()` for the total: it is calculated once, however many rules ask. + +A flagged sub-account order goes to its company while the sub-accounts feature is on. Any other flagged order is decided by the store, on the order screen of the Admin and Store panels. + ## Constraints - Never create a plugin or module outside `src/Plugins` or `src/Modules`. - Never use a system name that conflicts with an existing plugin or provider. diff --git a/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/ApplyOrderApprovalDecisionCommandHandler.cs b/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/ApplyOrderApprovalDecisionCommandHandler.cs new file mode 100644 index 000000000..8e8fa7e0f --- /dev/null +++ b/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/ApplyOrderApprovalDecisionCommandHandler.cs @@ -0,0 +1,160 @@ +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.Common.Stores; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.Messages; +using Grand.Domain.Customers; +using Grand.Domain.Orders; +using Grand.Domain.Payments; +using Grand.Mediator; +using Microsoft.Extensions.Logging; + +namespace Grand.Business.Checkout.Commands.Handlers.Orders; + +public class ApplyOrderApprovalDecisionCommandHandler : 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; + private readonly IStoreService _storeService; + + public ApplyOrderApprovalDecisionCommandHandler(IOrderApprovalService orderApprovalService, + ICustomerService customerService, ICustomerMessageProviderService messageProviderService, + IOrderService orderService, IStoreService storeService, IMediator mediator, + ILogger logger) + { + _orderApprovalService = orderApprovalService; + _customerService = customerService; + _messageProviderService = messageProviderService; + _orderService = orderService; + _storeService = storeService; + _mediator = mediator; + _logger = logger; + } + + public async Task Handle(ApplyOrderApprovalDecisionCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request.Approval); + ArgumentNullException.ThrowIfNull(request.DecidedBy); + + var approval = request.Approval; + if (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.DecidedBy.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 request closes first: of two decisions at once only one goes on, and a failed save leaves the order waiting + if (!await _orderApprovalService.Close(approval, + request.Approve ? OrderApprovalStatus.Approved : OrderApprovalStatus.Rejected, request.DecidedBy.Id, + NormalizeComment(request.Comment))) + return false; + + 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 goes back to pending", + approval.Id); + await Guard(approval.Id, "reopening the request", () => _orderApprovalService.Reopen(approval)); + return false; + } + + // from here the order is an ordinary one: a later failure must not undo or abort the decision + await Guard(order.Id, "order note", () => _orderService.InsertOrderNote(new OrderNote { + Note = $"Order {(request.Approve ? "approved" : "rejected")} by {request.DecidedBy.Email ?? request.DecidedBy.Id}", + DisplayToCustomer = false, + OrderId = order.Id + })); + + if (request.Approve) + await ContinueApproved(order, buyer, request, cancellationToken); + + await Guard(approval.Id, "decision e-mail", async () => + { + if (buyer == null) + return; + + // the buyer reads it: in the language the order was placed in, not the approver's + var store = await _storeService.GetStoreById(approval.StoreId); + var languageId = string.IsNullOrEmpty(order.CustomerLanguageId) + ? request.Language?.Id + : order.CustomerLanguageId; + + // a guest has no e-mail of their own: the address they gave is on the order + if (!string.IsNullOrWhiteSpace(buyer.Email)) + await _messageProviderService.SendOrderApprovalDecidedMessage(approval, buyer, store, languageId, + request.Host); + else if (!string.IsNullOrWhiteSpace(order.BillingAddress?.Email)) + await _messageProviderService.SendOrderApprovalDecidedMessage(approval, buyer, store, languageId, + request.Host, order.BillingAddress.Email); + }); + + return true; + } + + private async Task ContinueApproved(Order order, Customer buyer, ApplyOrderApprovalDecisionCommand request, + CancellationToken cancellationToken) + { + 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 not 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/Business/Grand.Business.Checkout/Commands/Handlers/Orders/DecideStoreOrderApprovalCommandHandler.cs b/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/DecideStoreOrderApprovalCommandHandler.cs new file mode 100644 index 000000000..d4c88fb34 --- /dev/null +++ b/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/DecideStoreOrderApprovalCommandHandler.cs @@ -0,0 +1,37 @@ +using Grand.Business.Core.Commands.Checkout.Orders; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Domain.Orders; +using Grand.Mediator; + +namespace Grand.Business.Checkout.Commands.Handlers.Orders; + +public class DecideStoreOrderApprovalCommandHandler : IRequestHandler +{ + private readonly IMediator _mediator; + private readonly IOrderApprovalService _orderApprovalService; + + public DecideStoreOrderApprovalCommandHandler(IOrderApprovalService orderApprovalService, IMediator mediator) + { + _orderApprovalService = orderApprovalService; + _mediator = mediator; + } + + public async Task Handle(DecideStoreOrderApprovalCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request.Order); + ArgumentNullException.ThrowIfNull(request.DecidedBy); + + // a company's request stays with the company: the store can only cancel the order + var approval = await _orderApprovalService.GetByOrderId(request.Order.Id); + if (approval is not { Decider: OrderApprovalDecider.Store }) + return false; + + return await _mediator.Send(new ApplyOrderApprovalDecisionCommand { + Approval = approval, + DecidedBy = request.DecidedBy, + Approve = request.Approve, + Comment = request.Comment, + Language = request.Language + }, cancellationToken); + } +} 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 adf806f57..0cb92c53a 100644 --- a/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/PlaceOrderCommandHandler.cs +++ b/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/PlaceOrderCommandHandler.cs @@ -48,6 +48,7 @@ public class PlaceOrderCommandHandler : IRequestHandler _logger; private readonly IMediator _mediator; private readonly IOrderApprovalService _orderApprovalService; + private readonly IOrderPlacementService _orderPlacementService; private readonly ITranslationService _translationService; private readonly ICatalogMessageProviderService _catalogMessageProviderService; private readonly IOrderService _orderService; @@ -103,6 +104,7 @@ public PlaceOrderCommandHandler( ICountryService countryService, IShoppingCartValidator shoppingCartValidator, IOrderApprovalService orderApprovalService, + IOrderPlacementService orderPlacementService, ITranslationService translationService, ShippingSettings shippingSettings, ShoppingCartSettings shoppingCartSettings, @@ -141,6 +143,7 @@ public PlaceOrderCommandHandler( _countryService = countryService; _shoppingCartValidator = shoppingCartValidator; _orderApprovalService = orderApprovalService; + _orderPlacementService = orderPlacementService; _translationService = translationService; _shippingSettings = shippingSettings; _shoppingCartSettings = shoppingCartSettings; @@ -184,10 +187,23 @@ public async Task Handle(PlaceOrderCommand command, Cancellati 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)) + //the rules are asked before any payment is taken: this answer is the authoritative one + var evaluation = await _orderPlacementService.Evaluate(details.Cart, details.Context, command.Language, + details.PaymentMethodSystemName, cancellationToken); + if (evaluation.Decision == OrderPlacementDecision.Block) + { + foreach (var reason in evaluation.Reasons) + result.AddError(reason); + return result; + } + + //an order that needs an approval is placed awaiting it, with an allowed payment method only + details.ApprovalRequired = evaluation.Decision == OrderPlacementDecision.RequireApproval; + details.ApprovalReasons = evaluation.Reasons.ToList(); + details.ApprovalStoreDecides = evaluation.StoreDecides; + //with nothing to pay the checkout skips the payment step and clears the method, so there is nothing to check + if (details.ApprovalRequired && details.OrderTotal != 0 && + !_orderApprovalService.IsPaymentMethodAllowed(details.PaymentMethodSystemName)) { result.AddError(_translationService.GetResource("Checkout.OrderApproval.PaymentMethodNotAllowed", command.Language.Id)); @@ -233,7 +249,10 @@ await _mediator.Send(new OrderNotificationCommand { await _mediator.Send(new CheckOrderStatusCommand { Order = result.PlacedOrder }, cancellationToken); //raise event - await _mediator.Publish(new OrderPlacedEvent(result.PlacedOrder), cancellationToken); + await _mediator.Publish(new OrderPlacedEvent(result.PlacedOrder) { + ApprovalReasons = details.ApprovalReasons.ToList(), + ApprovalStoreDecides = details.ApprovalStoreDecides + }, cancellationToken); //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) diff --git a/src/Business/Grand.Business.Checkout/Events/Orders/OrderCancelledOrderApprovalHandler.cs b/src/Business/Grand.Business.Checkout/Events/Orders/OrderCancelledOrderApprovalHandler.cs index f3624f159..cb2a7f70a 100644 --- a/src/Business/Grand.Business.Checkout/Events/Orders/OrderCancelledOrderApprovalHandler.cs +++ b/src/Business/Grand.Business.Checkout/Events/Orders/OrderCancelledOrderApprovalHandler.cs @@ -26,14 +26,10 @@ public async Task Handle(OrderCancelledEvent notification, CancellationToken can var order = notification.Order; try { - // a decision or a withdrawal cancels the order first and then saves its own status over this one + // a decision or a withdrawal closes the request before cancelling, so it is not pending here 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); + if (approval is { Status: OrderApprovalStatus.Pending }) + await _orderApprovalService.Close(approval, OrderApprovalStatus.Cancelled); } catch (Exception ex) { diff --git a/src/Business/Grand.Business.Checkout/Events/Orders/OrderPlacedOrderApprovalHandler.cs b/src/Business/Grand.Business.Checkout/Events/Orders/OrderPlacedOrderApprovalHandler.cs index 26c328187..18d424ea2 100644 --- a/src/Business/Grand.Business.Checkout/Events/Orders/OrderPlacedOrderApprovalHandler.cs +++ b/src/Business/Grand.Business.Checkout/Events/Orders/OrderPlacedOrderApprovalHandler.cs @@ -7,10 +7,11 @@ 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.Localization; using Grand.Domain.Orders; +using Grand.Domain.Stores; using Grand.Domain.Tax; using Grand.Mediator; using Microsoft.Extensions.Logging; @@ -22,8 +23,15 @@ namespace Grand.Business.Checkout.Events.Orders; /// public class OrderPlacedOrderApprovalHandler : INotificationHandler { + private const string AdminPanelArea = "Admin"; + private const string StorePanelArea = "Store"; + private readonly ICurrencyService _currencyService; private readonly ICustomerService _customerService; + private readonly CustomerSettings _customerSettings; + private readonly IGroupService _groupService; + private readonly LanguageSettings _languageSettings; + private readonly ISalesEmployeeService _salesEmployeeService; private readonly ILogger _logger; private readonly ICustomerMessageProviderService _messageProviderService; private readonly IOrderApprovalService _orderApprovalService; @@ -39,8 +47,16 @@ public OrderPlacedOrderApprovalHandler( IProductService productService, IPriceFormatter priceFormatter, ICurrencyService currencyService, + ISalesEmployeeService salesEmployeeService, + IGroupService groupService, + CustomerSettings customerSettings, + LanguageSettings languageSettings, ILogger logger) { + _salesEmployeeService = salesEmployeeService; + _groupService = groupService; + _customerSettings = customerSettings; + _languageSettings = languageSettings; _orderApprovalService = orderApprovalService; _customerService = customerService; _messageProviderService = messageProviderService; @@ -59,7 +75,7 @@ public async Task Handle(OrderPlacedEvent notification, CancellationToken cancel try { - await CreateApprovalRequest(order); + await CreateApprovalRequest(order, notification.ApprovalReasons, notification.ApprovalStoreDecides); } catch (Exception ex) { @@ -70,7 +86,7 @@ public async Task Handle(OrderPlacedEvent notification, CancellationToken cancel } } - private async Task CreateApprovalRequest(Order order) + private async Task CreateApprovalRequest(Order order, IReadOnlyList reasons, bool storeDecides) { // one request per order, even if the event is raised twice if (await _orderApprovalService.GetByOrderId(order.Id) != null) @@ -85,17 +101,26 @@ private async Task CreateApprovalRequest(Order order) return; } - var companyOwnerId = string.IsNullOrEmpty(order.OwnerId) ? OrderApprovalRules.CompanyId(buyer) : order.OwnerId; + // a sub-account's company decides, while the storefront has the approvals screen and someone there can be + // told; anyone else has no company to ask, and a rule may keep the decision with the store. + // Not the order's OwnerId: it holds the customer's own id when there is no owner + IList approvers = !storeDecides && !_customerSettings.HideSubAccountsTab && + !string.IsNullOrEmpty(buyer.OwnerId) + ? await GetApprovers(buyer, buyer.OwnerId, order.StoreId) + : []; + var companyDecides = approvers.Count > 0; var currency = await _currencyService.GetCurrencyByCode(order.CustomerCurrencyCode); var approval = new OrderApproval { StoreId = order.StoreId, - CompanyOwnerId = companyOwnerId, + Decider = companyDecides ? OrderApprovalDecider.Company : OrderApprovalDecider.Store, + CompanyOwnerId = companyDecides ? buyer.OwnerId : null, CustomerId = order.CustomerId, Status = OrderApprovalStatus.Pending, OrderId = order.Id, Amount = order.OrderTotal, CurrencyCode = order.CustomerCurrencyCode, + Reasons = reasons.ToList(), Summary = PrepareSummary(order, currency) }; foreach (var item in await PrepareItems(order, currency)) @@ -104,7 +129,13 @@ private async Task CreateApprovalRequest(Order order) await _orderApprovalService.Insert(approval); var store = await _storeService.GetStoreById(order.StoreId); - foreach (var approver in await GetApprovers(buyer, companyOwnerId, order.StoreId)) + if (!companyDecides) + { + await NotifyStore(approval, order, buyer, store); + return; + } + + foreach (var approver in approvers) try { await _messageProviderService.SendOrderApprovalRequestedMessage(approval, buyer, approver, store, @@ -118,6 +149,65 @@ await _messageProviderService.SendOrderApprovalRequestedMessage(approval, buyer, } } + /// + /// The e-mail only tells someone in the store: anyone with access to the order may decide it + /// + private async Task NotifyStore(OrderApproval approval, Order order, Customer buyer, Store store) + { + foreach (var (email, name, panelArea) in await GetStoreRecipients(order, buyer, store)) + try + { + await _messageProviderService.SendOrderApprovalStaffMessage(approval, buyer, store, email, name, + panelArea, _languageSettings.DefaultAdminLanguageId); + } + catch (Exception ex) + { + // a failed e-mail must not stop the others, nor the order + _logger.LogError(ex, "Order approval e-mail to the store failed. Order {OrderId}", order.Id); + } + } + + /// + /// The store's owner account, else nobody named: the message then goes to the e-mail account of its template. + /// The order's sales employee as well: that record does not prove its person can open the panel, so they are + /// never the only one told. Never the buyer: nobody decides their own order. + /// + private async Task> GetStoreRecipients(Order order, + Customer buyer, Store store) + { + var recipients = new List<(string Email, string Name, string PanelArea)> { + await GetOwnerRecipient(buyer, store) + }; + + if (!string.IsNullOrEmpty(order.SeId)) + { + var salesEmployee = await _salesEmployeeService.GetSalesEmployeeById(order.SeId); + if (salesEmployee is { Active: true } && !string.IsNullOrWhiteSpace(salesEmployee.Email) && + !string.Equals(salesEmployee.Email, buyer.Email, StringComparison.OrdinalIgnoreCase) && + !string.Equals(salesEmployee.Email, recipients[0].Email, StringComparison.OrdinalIgnoreCase)) + recipients.Add((salesEmployee.Email, salesEmployee.Name, AdminPanelArea)); + } + + return recipients; + } + + private async Task<(string Email, string Name, string PanelArea)> GetOwnerRecipient(Customer buyer, Store store) + { + if (!string.IsNullOrEmpty(store.OwnerCustomerId) && store.OwnerCustomerId != buyer.Id) + { + var owner = await _customerService.GetCustomerById(store.OwnerCustomerId); + if (owner is { Active: true, Deleted: false } && !string.IsNullOrWhiteSpace(owner.Email)) + { + // the mail carries the buyer's data, and the account may have left this store since it was chosen + var managesThisStore = owner.StaffStoreId == store.Id && await _groupService.IsStoreManager(owner); + if (managesThisStore || await _groupService.IsAdmin(owner)) + return (owner.Email, owner.GetFullName(), managesThisStore ? StorePanelArea : AdminPanelArea); + } + } + + return (null, null, AdminPanelArea); + } + private async Task> PrepareItems(Order order, Currency currency) { var includingTax = order.CustomerTaxDisplayTypeId == TaxDisplayType.IncludingTax; diff --git a/src/Business/Grand.Business.Checkout/Services/Orders/OrderApprovalLimitRuleProvider.cs b/src/Business/Grand.Business.Checkout/Services/Orders/OrderApprovalLimitRuleProvider.cs new file mode 100644 index 000000000..777a138e9 --- /dev/null +++ b/src/Business/Grand.Business.Checkout/Services/Orders/OrderApprovalLimitRuleProvider.cs @@ -0,0 +1,73 @@ +using Grand.Business.Core.Interfaces.Catalog.Prices; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Utilities.Checkout; +using Grand.Domain.Customers; + +namespace Grand.Business.Checkout.Services.Orders; + +/// +/// The built-in rule: an order of a company sub-account over its limit waits for an approval +/// +public class OrderApprovalLimitRuleProvider : IOrderPlacementRuleProvider +{ + private readonly ICurrencyService _currencyService; + private readonly CustomerSettings _customerSettings; + private readonly IPriceFormatter _priceFormatter; + private readonly ITranslationService _translationService; + + public OrderApprovalLimitRuleProvider(CustomerSettings customerSettings, ICurrencyService currencyService, + IPriceFormatter priceFormatter, ITranslationService translationService) + { + _customerSettings = customerSettings; + _currencyService = currencyService; + _priceFormatter = priceFormatter; + _translationService = translationService; + } + + public string ConfigurationUrl => null; + public string SystemName => "OrderPlacementRule.ApprovalLimit"; + public string FriendlyName => SystemName; + public int Priority => 0; + public IList LimitedToStores => []; + public IList LimitedToGroups => []; + + public async Task Evaluate(OrderPlacementContext context, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + var customer = context.Calculation.Customer; + var limit = customer.OrderApprovalLimit; + + // not a limited buyer: returning here keeps a retail checkout free of a total calculation + if (_customerSettings.HideSubAccountsTab || string.IsNullOrEmpty(customer.OwnerId) || + OrderApprovalRules.CanApprove(customer) || !limit.HasValue) + return OrderPlacementRuleResult.Allow(); + + // the total is only known once shipping is chosen; until then the rule is not evaluated + var total = await context.GetTotal(); + if (!total.HasValue || total.Value <= limit.Value) + return OrderPlacementRuleResult.Allow(); + + return OrderPlacementRuleResult.RequireApproval(await LimitExceededText(limit.Value, context)); + } + + private async Task LimitExceededText(double limit, OrderPlacementContext context) + { + var currency = context.Calculation.Currency; + var message = _translationService.GetResource("Checkout.OrderApproval.LimitExceeded", context.Language.Id); + try + { + return string.Format(message, + _priceFormatter.FormatPrice(await _currencyService.ConvertFromPrimaryStoreCurrency(limit, currency), + currency)); + } + catch (FormatException) + { + //the text is editable by an operator: a broken placeholder must not break the checkout + return message; + } + } +} diff --git a/src/Business/Grand.Business.Checkout/Services/Orders/OrderApprovalService.cs b/src/Business/Grand.Business.Checkout/Services/Orders/OrderApprovalService.cs index f1fe7cdeb..606bcc688 100644 --- a/src/Business/Grand.Business.Checkout/Services/Orders/OrderApprovalService.cs +++ b/src/Business/Grand.Business.Checkout/Services/Orders/OrderApprovalService.cs @@ -1,8 +1,6 @@ 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; @@ -17,14 +15,10 @@ 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; } @@ -34,8 +28,6 @@ public OrderApprovalService(IRepository repository, #region Fields private readonly IRepository _repository; - private readonly IOrderCalculationService _orderCalculationService; - private readonly CustomerSettings _customerSettings; private readonly OrderApprovalSettings _approvalSettings; private readonly IMediator _mediator; @@ -82,7 +74,8 @@ public virtual async Task> GetForCustomer(string custo return new PagedList([], pageIndex, pageSize); var query = _repository.Table - .Where(x => x.CustomerId == customerId && x.StoreId == storeId) + // a request of the store has no page on the storefront list; not "== Company", a document without the field would not match + .Where(x => x.CustomerId == customerId && x.StoreId == storeId && x.Decider != OrderApprovalDecider.Store) .OrderByDescending(x => x.CreatedOnUtc); return await _repository.PagedAsync(query, pageIndex, pageSize); @@ -96,32 +89,53 @@ public virtual async Task Insert(OrderApproval approval) await _mediator.EntityInserted(approval); } - public virtual async Task Update(OrderApproval approval) + public virtual async Task Close(OrderApproval approval, OrderApprovalStatus status, + string decidedByCustomerId = null, string decisionComment = null) { ArgumentNullException.ThrowIfNull(approval); - await _repository.UpdateAsync(approval); + var closeId = Guid.NewGuid().ToString("N"); + var decidedOnUtc = DateTime.UtcNow; + await _repository.UpdateOneAsync(x => x.Id == approval.Id && x.Status == OrderApprovalStatus.Pending, + UpdateBuilder.Create() + .Set(x => x.Status, status) + .Set(x => x.DecidedOnUtc, decidedOnUtc) + .Set(x => x.DecidedByCustomerId, decidedByCustomerId) + .Set(x => x.DecisionComment, decisionComment) + .Set(x => x.CloseId, closeId)); + + // the update reports nothing, so the record tells which of two concurrent callers won + var current = await _repository.GetByIdAsync(approval.Id); + if (current?.CloseId != closeId) + return false; + + approval.Status = status; + approval.DecidedOnUtc = decidedOnUtc; + approval.DecidedByCustomerId = decidedByCustomerId; + approval.DecisionComment = decisionComment; + approval.CloseId = closeId; await _mediator.EntityUpdated(approval); + return true; } - public virtual async Task RequiresApproval(IList cart, - CalculationContext context) + public virtual async Task Reopen(OrderApproval approval) { - 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; + ArgumentNullException.ThrowIfNull(approval); - // 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); + await _repository.UpdateOneAsync(x => x.Id == approval.Id && x.CloseId == approval.CloseId, + UpdateBuilder.Create() + .Set(x => x.Status, OrderApprovalStatus.Pending) + .Set(x => x.DecidedOnUtc, null) + .Set(x => x.DecidedByCustomerId, null) + .Set(x => x.DecisionComment, null) + .Set(x => x.CloseId, null)); + + approval.Status = OrderApprovalStatus.Pending; + approval.DecidedOnUtc = null; + approval.DecidedByCustomerId = null; + approval.DecisionComment = null; + approval.CloseId = null; + await _mediator.EntityUpdated(approval); } public virtual bool IsPaymentMethodAllowed(string paymentMethodSystemName) diff --git a/src/Business/Grand.Business.Checkout/Services/Orders/OrderPlacementService.cs b/src/Business/Grand.Business.Checkout/Services/Orders/OrderPlacementService.cs new file mode 100644 index 000000000..29998c216 --- /dev/null +++ b/src/Business/Grand.Business.Checkout/Services/Orders/OrderPlacementService.cs @@ -0,0 +1,97 @@ +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Utilities.Checkout; +using Grand.Domain.Localization; +using Grand.Domain.Orders; +using Grand.Infrastructure.Extensions; +using Microsoft.Extensions.Logging; + +namespace Grand.Business.Checkout.Services.Orders; + +/// +/// Order placement service +/// +public class OrderPlacementService : IOrderPlacementService +{ + private const int MaxReasonLength = 500; + + private readonly ILogger _logger; + private readonly IOrderCalculationService _orderCalculationService; + private readonly IEnumerable _rules; + private readonly ITranslationService _translationService; + + public OrderPlacementService(IEnumerable rules, + IOrderCalculationService orderCalculationService, + ITranslationService translationService, + ILogger logger) + { + _rules = rules; + _orderCalculationService = orderCalculationService; + _translationService = translationService; + _logger = logger; + } + + public virtual async Task Evaluate(IList cart, + CalculationContext calculation, Language language, string paymentMethodSystemName, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(cart); + ArgumentNullException.ThrowIfNull(calculation); + ArgumentNullException.ThrowIfNull(language); + + var context = new OrderPlacementContext(cart, calculation, language, paymentMethodSystemName, + async () => (await _orderCalculationService.GetShoppingCartTotal(cart, calculation))?.Total); + + // every rule runs, also after a block: the buyer sees all the reasons at once + var results = new List(); + foreach (var rule in _rules + .Where(x => x.IsAuthenticateStore(calculation.Store) && x.IsAuthenticateGroup(calculation.Customer)) + .OrderBy(x => x.Priority)) + results.Add(await Run(rule, context, cancellationToken)); + + var decision = results.Count > 0 ? results.Max(x => x.Decision) : OrderPlacementDecision.Allow; + if (decision == OrderPlacementDecision.Allow) + return new OrderPlacementEvaluation(decision, []); + + var deciding = results.Where(x => x.Decision == decision).ToList(); + return new OrderPlacementEvaluation(decision, + deciding.Select(x => Reason(x, language)).Distinct().ToList(), + deciding.Any(x => x.StoreDecides)); + } + + /// + /// A rule that fails blocks the order: a fault must not switch a purchasing control off, and the buyer never + /// reads the exception + /// + private async Task Run(IOrderPlacementRuleProvider rule, OrderPlacementContext context, + CancellationToken cancellationToken) + { + try + { + return await rule.Evaluate(context, cancellationToken) + ?? throw new InvalidOperationException("The rule returned no result"); + } + // an aborted request is nobody's fault; a rule's own timeout is a failure like any other + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Order placement rule {SystemName} failed, the order is blocked", rule.SystemName); + return OrderPlacementRuleResult.Block( + _translationService.GetResource("Checkout.OrderPlacement.RuleFailed", context.Language.Id)); + } + } + + private string Reason(OrderPlacementRuleResult result, Language language) + { + var reason = result.Reason?.Trim(); + if (string.IsNullOrEmpty(reason)) + return _translationService.GetResource(result.Decision == OrderPlacementDecision.Block + ? "Checkout.OrderPlacement.Blocked" + : "Checkout.OrderPlacement.ApprovalRequired", language.Id); + + return reason.Length > MaxReasonLength ? reason[..MaxReasonLength] : reason; + } +} diff --git a/src/Business/Grand.Business.Checkout/Startup/StartupApplication.cs b/src/Business/Grand.Business.Checkout/Startup/StartupApplication.cs index 42661d52c..143cbadcd 100644 --- a/src/Business/Grand.Business.Checkout/Startup/StartupApplication.cs +++ b/src/Business/Grand.Business.Checkout/Startup/StartupApplication.cs @@ -51,6 +51,8 @@ private static void RegisterOrdersService(IServiceCollection serviceCollection) serviceCollection.AddScoped(); serviceCollection.AddScoped(); serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); serviceCollection.AddScoped(); serviceCollection.AddScoped(); serviceCollection.AddScoped(); diff --git a/src/Business/Grand.Business.Core/Commands/Checkout/Orders/ApplyOrderApprovalDecisionCommand.cs b/src/Business/Grand.Business.Core/Commands/Checkout/Orders/ApplyOrderApprovalDecisionCommand.cs new file mode 100644 index 000000000..6216d6e5f --- /dev/null +++ b/src/Business/Grand.Business.Core/Commands/Checkout/Orders/ApplyOrderApprovalDecisionCommand.cs @@ -0,0 +1,25 @@ +using Grand.Domain.Customers; +using Grand.Domain.Localization; +using Grand.Domain.Orders; +using Grand.Domain.Stores; +using Grand.Mediator; + +namespace Grand.Business.Core.Commands.Checkout.Orders; + +/// +/// Applies a decision to an order approval. The caller has checked that the customer may decide it; this command +/// refuses, with false, a request that is no longer open and a customer deciding their own order. +/// +public class ApplyOrderApprovalDecisionCommand : IRequest +{ + public OrderApproval Approval { get; set; } + public Customer DecidedBy { get; set; } + public bool Approve { get; set; } + public string Comment { get; set; } + public Language Language { get; set; } + + /// + /// Host of the storefront request; empty in a back office panel, links then use the store's primary domain + /// + public DomainHost Host { get; set; } +} diff --git a/src/Business/Grand.Business.Core/Commands/Checkout/Orders/DecideStoreOrderApprovalCommand.cs b/src/Business/Grand.Business.Core/Commands/Checkout/Orders/DecideStoreOrderApprovalCommand.cs new file mode 100644 index 000000000..6bd0924b2 --- /dev/null +++ b/src/Business/Grand.Business.Core/Commands/Checkout/Orders/DecideStoreOrderApprovalCommand.cs @@ -0,0 +1,19 @@ +using Grand.Domain.Customers; +using Grand.Domain.Localization; +using Grand.Domain.Orders; +using Grand.Mediator; + +namespace Grand.Business.Core.Commands.Checkout.Orders; + +/// +/// Approves or rejects an order whose approval the store decides. The caller has checked that the customer may +/// manage this order; a request the company decides is refused with false. +/// +public class DecideStoreOrderApprovalCommand : IRequest +{ + public Order Order { get; set; } + public Customer DecidedBy { get; set; } + public bool Approve { get; set; } + public string Comment { get; set; } + public Language Language { get; set; } +} diff --git a/src/Business/Grand.Business.Core/Events/Checkout/Orders/OrderPlacedEvent.cs b/src/Business/Grand.Business.Core/Events/Checkout/Orders/OrderPlacedEvent.cs index f23eb7111..747a3cd01 100644 --- a/src/Business/Grand.Business.Core/Events/Checkout/Orders/OrderPlacedEvent.cs +++ b/src/Business/Grand.Business.Core/Events/Checkout/Orders/OrderPlacedEvent.cs @@ -17,4 +17,14 @@ public OrderPlacedEvent(Order order) /// Order /// public Order Order { get; private set; } + + /// + /// Why the order awaits an approval, when it does + /// + public IReadOnlyList ApprovalReasons { get; set; } = []; + + /// + /// A rule wants the store to decide the approval, also for a sub-account's order + /// + public bool ApprovalStoreDecides { get; set; } } \ No newline at end of file diff --git a/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IOrderApprovalService.cs b/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IOrderApprovalService.cs index a5e22d02c..30ab0dbbe 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IOrderApprovalService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IOrderApprovalService.cs @@ -1,4 +1,3 @@ -using Grand.Business.Core.Utilities.Checkout; using Grand.Domain; using Grand.Domain.Orders; @@ -51,19 +50,21 @@ Task> GetForCustomer(string customerId, string storeId Task Insert(OrderApproval approval); /// - /// Updates an approval + /// Closes a pending request. Atomic: of concurrent callers only one succeeds, the others change nothing. /// - /// Approval - Task Update(OrderApproval approval); + /// Approval; receives the closed state when the call succeeds + /// The status to close it with + /// Who decided; null when nobody did + /// The approver's comment + /// Whether this call closed the request + Task Close(OrderApproval approval, OrderApprovalStatus status, string decidedByCustomerId = null, + string decisionComment = null); /// - /// 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. + /// Makes a request closed by pending again, when what should follow the close has failed /// - /// Shopping cart of the customer - /// Customer, store and currency the cart is calculated for - Task RequiresApproval(IList cart, CalculationContext context); + /// The approval as left it + Task Reopen(OrderApproval approval); /// /// Gets a value indicating whether the payment method may be used for an order that needs an approval diff --git a/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IOrderPlacementRuleProvider.cs b/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IOrderPlacementRuleProvider.cs new file mode 100644 index 000000000..e2d57378a --- /dev/null +++ b/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IOrderPlacementRuleProvider.cs @@ -0,0 +1,44 @@ +using Grand.Business.Core.Utilities.Checkout; +using Grand.Infrastructure.Plugins; + +namespace Grand.Business.Core.Interfaces.Checkout.Orders; + +/// +/// A rule a plugin adds to order placement: it allows the order, requires an approval for it, or blocks it. +/// Any number of rules can be active. If any rule blocks, the order cannot be placed; otherwise, if any rule +/// requires an approval, the order waits for one. A rule that allows the order never cancels another rule's decision. +/// +/// +/// A rule runs several times per checkout, also on a page view: it must be fast and have no side effects. +/// +/// +/// The payment method and the total can be null early in checkout, and the rule must decide sensibly then. +/// The call made when the order is placed is the authoritative one. +/// +/// +/// The reason is shown to the buyer and to whoever decides the approval: plain text, nothing confidential. +/// +/// +/// A rule that throws blocks the order, so a fault cannot switch a purchasing control off. +/// +/// +/// By default a sub-account's company decides the approval. Return +/// for a control the buyer's company must not +/// release; the store then decides, whichever rule asked. +/// +/// +/// Do not inject . Use for +/// the total, not . +/// +/// +/// +public interface IOrderPlacementRuleProvider : IProvider +{ + /// + /// Decides about the order being placed + /// + /// The order being placed + /// Cancelled when the buyer's request is aborted; pass it to any external call + Task Evaluate(OrderPlacementContext context, + CancellationToken cancellationToken = default); +} diff --git a/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IOrderPlacementService.cs b/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IOrderPlacementService.cs new file mode 100644 index 000000000..d79434905 --- /dev/null +++ b/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IOrderPlacementService.cs @@ -0,0 +1,22 @@ +using Grand.Business.Core.Utilities.Checkout; +using Grand.Domain.Localization; +using Grand.Domain.Orders; + +namespace Grand.Business.Core.Interfaces.Checkout.Orders; + +/// +/// Asks the order placement rules about the order being placed +/// +public interface IOrderPlacementService +{ + /// + /// Runs every rule that applies to the store and the customer and combines their answers + /// + /// Shopping cart of the customer + /// Customer, store and currency the cart is calculated for + /// Language the reasons are written in + /// Chosen payment method; null until the buyer has chosen one + /// Cancellation token, passed on to every rule + Task Evaluate(IList cart, CalculationContext calculation, + Language language, string paymentMethodSystemName, CancellationToken cancellationToken = default); +} diff --git a/src/Business/Grand.Business.Core/Interfaces/Messages/ICustomerMessageProviderService.cs b/src/Business/Grand.Business.Core/Interfaces/Messages/ICustomerMessageProviderService.cs index 809eeccf1..24dbae0ea 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Messages/ICustomerMessageProviderService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Messages/ICustomerMessageProviderService.cs @@ -140,4 +140,33 @@ Task SendOrderApprovalRequestedMessage(OrderApproval approval, Customer buy /// Queued email identifier Task SendOrderApprovalDecidedMessage(OrderApproval approval, Customer buyer, Store store, string languageId, DomainHost host = null); + + /// + /// Sends an 'Order approval decided' message to the given address, for a buyer who has none of their own + /// + /// Order approval + /// Customer who asked for the approval + /// 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 + /// + /// Recipient + /// Queued email identifier + Task SendOrderApprovalDecidedMessage(OrderApproval approval, Customer buyer, Store store, + string languageId, DomainHost host, string toEmail); + + /// + /// Sends an 'Order approval requested' message to the store, when the store decides the request + /// + /// Order approval + /// Customer who placed the order + /// Store + /// Recipient; empty sends it to the e-mail account of the message template + /// Recipient name + /// The back office panel the recipient works in + /// Message language identifier + /// Queued email identifier + Task SendOrderApprovalStaffMessage(OrderApproval approval, Customer buyer, Store store, string toEmail, + string toName, string panelArea, string languageId); } diff --git a/src/Business/Grand.Business.Core/Queries/Customers/GetCustomerQuery.cs b/src/Business/Grand.Business.Core/Queries/Customers/GetCustomerQuery.cs index 086ae33aa..b52d579b7 100644 --- a/src/Business/Grand.Business.Core/Queries/Customers/GetCustomerQuery.cs +++ b/src/Business/Grand.Business.Core/Queries/Customers/GetCustomerQuery.cs @@ -13,6 +13,10 @@ public class GetCustomerQuery : IRequest> public DateTime? CreatedToUtc { get; set; } = null; public string AffiliateId { get; set; } = ""; public string VendorId { get; set; } = ""; + + /// The store the staff account manages + public string StaffStoreId { get; set; } = ""; + public string StoreId { get; set; } = ""; public string OwnerId { get; set; } = ""; public string SalesEmployeeId { get; set; } = ""; diff --git a/src/Business/Grand.Business.Core/Utilities/Checkout/OrderApprovalCheckoutState.cs b/src/Business/Grand.Business.Core/Utilities/Checkout/OrderApprovalCheckoutState.cs deleted file mode 100644 index dee69ac42..000000000 --- a/src/Business/Grand.Business.Core/Utilities/Checkout/OrderApprovalCheckoutState.cs +++ /dev/null @@ -1,9 +0,0 @@ -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/OrderPlacementContext.cs b/src/Business/Grand.Business.Core/Utilities/Checkout/OrderPlacementContext.cs new file mode 100644 index 000000000..be04ad7e2 --- /dev/null +++ b/src/Business/Grand.Business.Core/Utilities/Checkout/OrderPlacementContext.cs @@ -0,0 +1,52 @@ +using Grand.Domain.Localization; +using Grand.Domain.Orders; + +namespace Grand.Business.Core.Utilities.Checkout; + +/// +/// The order being placed, as an order placement rule sees it +/// +public class OrderPlacementContext +{ + private readonly Func> _getTotal; + private Task _total; + + public OrderPlacementContext(IList cart, CalculationContext calculation, Language language, + string paymentMethodSystemName, Func> getTotal) + { + Cart = cart; + Calculation = calculation; + Language = language; + PaymentMethodSystemName = paymentMethodSystemName; + _getTotal = getTotal; + } + + /// + /// Gets the shopping cart + /// + public IList Cart { get; } + + /// + /// Gets the customer, store, currency and tax display the cart is calculated for + /// + public CalculationContext Calculation { get; } + + /// + /// Gets the language to write the reason in + /// + public Language Language { get; } + + /// + /// Gets the chosen payment method; null until the buyer has chosen one + /// + public string PaymentMethodSystemName { get; } + + /// + /// Gets the order total in the primary store currency; null until shipping is chosen. + /// It is calculated once, on first use, however many rules ask for it. + /// + public Task GetTotal() + { + return _total ??= _getTotal(); + } +} diff --git a/src/Business/Grand.Business.Core/Utilities/Checkout/OrderPlacementDecision.cs b/src/Business/Grand.Business.Core/Utilities/Checkout/OrderPlacementDecision.cs new file mode 100644 index 000000000..695b0afb5 --- /dev/null +++ b/src/Business/Grand.Business.Core/Utilities/Checkout/OrderPlacementDecision.cs @@ -0,0 +1,11 @@ +namespace Grand.Business.Core.Utilities.Checkout; + +/// +/// What an order placement rule decides about the order being placed +/// +public enum OrderPlacementDecision +{ + Allow = 0, + RequireApproval = 10, + Block = 20 +} diff --git a/src/Business/Grand.Business.Core/Utilities/Checkout/OrderPlacementEvaluation.cs b/src/Business/Grand.Business.Core/Utilities/Checkout/OrderPlacementEvaluation.cs new file mode 100644 index 000000000..c4fc48cab --- /dev/null +++ b/src/Business/Grand.Business.Core/Utilities/Checkout/OrderPlacementEvaluation.cs @@ -0,0 +1,10 @@ +namespace Grand.Business.Core.Utilities.Checkout; + +/// +/// The combined answer of all order placement rules +/// +/// Any block blocks; otherwise any required approval requires it; otherwise the order is allowed +/// The distinct reasons of the rules that produced this decision +/// A rule requiring the approval wants the store to decide it +public record OrderPlacementEvaluation(OrderPlacementDecision Decision, IReadOnlyList Reasons, + bool StoreDecides = false); diff --git a/src/Business/Grand.Business.Core/Utilities/Checkout/OrderPlacementRuleResult.cs b/src/Business/Grand.Business.Core/Utilities/Checkout/OrderPlacementRuleResult.cs new file mode 100644 index 000000000..5f1eaf49e --- /dev/null +++ b/src/Business/Grand.Business.Core/Utilities/Checkout/OrderPlacementRuleResult.cs @@ -0,0 +1,30 @@ +namespace Grand.Business.Core.Utilities.Checkout; + +/// +/// The answer of one order placement rule +/// +/// What the rule decides +/// Plain text shown to the buyer and to whoever decides the approval +/// The store decides the approval, also for a sub-account's order +public record OrderPlacementRuleResult(OrderPlacementDecision Decision, string Reason, bool StoreDecides = false) +{ + public static OrderPlacementRuleResult Allow() + { + return new OrderPlacementRuleResult(OrderPlacementDecision.Allow, null); + } + + public static OrderPlacementRuleResult RequireApproval(string reason) + { + return new OrderPlacementRuleResult(OrderPlacementDecision.RequireApproval, reason); + } + + public static OrderPlacementRuleResult RequireStoreApproval(string reason) + { + return new OrderPlacementRuleResult(OrderPlacementDecision.RequireApproval, reason, true); + } + + public static OrderPlacementRuleResult Block(string reason) + { + return new OrderPlacementRuleResult(OrderPlacementDecision.Block, reason); + } +} diff --git a/src/Business/Grand.Business.Core/Utilities/Checkout/PlaceOrderContainer.cs b/src/Business/Grand.Business.Core/Utilities/Checkout/PlaceOrderContainer.cs index ceeab4694..32d7abcaf 100644 --- a/src/Business/Grand.Business.Core/Utilities/Checkout/PlaceOrderContainer.cs +++ b/src/Business/Grand.Business.Core/Utilities/Checkout/PlaceOrderContainer.cs @@ -41,9 +41,20 @@ public class PlaceOrderContainer public string PurchaseOrderNumber { get; set; } /// - /// The order total exceeds the customer's order limit, so the order is placed awaiting an approval + /// An order placement rule requires an approval, so the order is placed awaiting one /// public bool ApprovalRequired { get; set; } + + /// + /// Why the order needs an approval + /// + public IList ApprovalReasons { get; set; } = new List(); + + /// + /// A rule wants the store to decide the approval, also for a sub-account's order + /// + public bool ApprovalStoreDecides { get; set; } + public Address BillingAddress { get; set; } public Address ShippingAddress { get; set; } public ShippingStatus ShippingStatus { get; set; } diff --git a/src/Business/Grand.Business.Customers/Queries/Handlers/GetCustomerQueryHandler.cs b/src/Business/Grand.Business.Customers/Queries/Handlers/GetCustomerQueryHandler.cs index 6502b610c..32900adf3 100644 --- a/src/Business/Grand.Business.Customers/Queries/Handlers/GetCustomerQueryHandler.cs +++ b/src/Business/Grand.Business.Customers/Queries/Handlers/GetCustomerQueryHandler.cs @@ -29,6 +29,8 @@ public async Task> Handle(GetCustomerQuery request, Cancell query = query.Where(c => request.AffiliateId == c.AffiliateId); if (!string.IsNullOrEmpty(request.VendorId)) query = query.Where(c => request.VendorId == c.VendorId); + if (!string.IsNullOrEmpty(request.StaffStoreId)) + query = query.Where(c => c.StaffStoreId == request.StaffStoreId); if (!string.IsNullOrEmpty(request.StoreId)) query = query.Where(c => c.StoreId == request.StoreId); if (!string.IsNullOrEmpty(request.OwnerId)) diff --git a/src/Business/Grand.Business.Messages/Commands/Handlers/Messages/GetOrderApprovalTokensCommandHandler.cs b/src/Business/Grand.Business.Messages/Commands/Handlers/Messages/GetOrderApprovalTokensCommandHandler.cs index 667526a7f..0067ff0d4 100644 --- a/src/Business/Grand.Business.Messages/Commands/Handlers/Messages/GetOrderApprovalTokensCommandHandler.cs +++ b/src/Business/Grand.Business.Messages/Commands/Handlers/Messages/GetOrderApprovalTokensCommandHandler.cs @@ -3,6 +3,7 @@ using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Messages.Commands.Tokens; using Grand.Business.Messages.DotLiquidDrops; +using Grand.Domain.Common; using Grand.Domain.Customers; using Grand.Domain.Orders; using Grand.Mediator; @@ -29,29 +30,54 @@ public async Task Handle(GetOrderApprovalTokensCommand requ //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 requestUrl = $"{url}/account/orderapproval?id={WebUtility.UrlEncode(approval.Id)}"; + // without an order the link leads to the request itself + var orderUrl = order == null ? requestUrl : $"{url}/orderdetails/{WebUtility.UrlEncode(approval.OrderId)}"; + var liquidOrderApproval = new LiquidOrderApproval { - BuyerName = request.Buyer.GetFullName(), - BuyerEmail = request.Buyer.Email, + // a guest has no name or e-mail of their own: what they gave is on the order + BuyerName = BuyerName(request.Buyer, order?.BillingAddress), + BuyerEmail = string.IsNullOrEmpty(request.Buyer.Email) ? order?.BillingAddress?.Email : 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)}", + // the storefront's approvals screen only carries the company's requests + Url = approval.Decider == OrderApprovalDecider.Store ? orderUrl : requestUrl, 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)}" + OrderUrl = orderUrl, + ReasonsHtml = ReasonsHtml(approval.Reasons), + PanelUrl = string.IsNullOrEmpty(request.PanelArea) + ? string.Empty + : $"{url}/{request.PanelArea}/Order/Edit/{WebUtility.UrlEncode(approval.OrderId)}" }; return liquidOrderApproval; } + private static string BuyerName(Customer buyer, Address billingAddress) + { + var name = buyer.GetFullName(); + return string.IsNullOrWhiteSpace(name) + ? $"{billingAddress?.FirstName} {billingAddress?.LastName}".Trim() + : name; + } + private static string Encode(string value) { return WebUtility.HtmlEncode(value ?? string.Empty); } + /// + /// A reason comes from a plugin, so it is encoded like every other value of the message + /// + private static string ReasonsHtml(ICollection reasons) + { + return reasons == null || reasons.Count == 0 + ? string.Empty + : $"
    {string.Concat(reasons.Select(x => $"
  • {Encode(x)}
  • "))}
"; + } + private string Label(string key, string defaultValue, string languageId) { return Encode(_translationService.GetResource(key, languageId, defaultValue)); diff --git a/src/Business/Grand.Business.Messages/Commands/Tokens/GetOrderApprovalTokensCommand.cs b/src/Business/Grand.Business.Messages/Commands/Tokens/GetOrderApprovalTokensCommand.cs index 18bace2f5..8d7c282a2 100644 --- a/src/Business/Grand.Business.Messages/Commands/Tokens/GetOrderApprovalTokensCommand.cs +++ b/src/Business/Grand.Business.Messages/Commands/Tokens/GetOrderApprovalTokensCommand.cs @@ -14,4 +14,9 @@ public class GetOrderApprovalTokensCommand : IRequest public Store Store { get; set; } public DomainHost Host { get; set; } public Language Language { get; set; } + + /// + /// The back office panel the recipient works in; empty for a message to the buyer + /// + public string PanelArea { get; set; } } diff --git a/src/Business/Grand.Business.Messages/DotLiquidDrops/LiquidObjectBuilder.cs b/src/Business/Grand.Business.Messages/DotLiquidDrops/LiquidObjectBuilder.cs index 3e25a75a7..b015763aa 100644 --- a/src/Business/Grand.Business.Messages/DotLiquidDrops/LiquidObjectBuilder.cs +++ b/src/Business/Grand.Business.Messages/DotLiquidDrops/LiquidObjectBuilder.cs @@ -69,12 +69,13 @@ public LiquidObjectBuilder AddOrderTokens(Order order, Customer customer, Store } public LiquidObjectBuilder AddOrderApprovalTokens(OrderApproval approval, Customer buyer, Store store, - DomainHost host, Language language) + DomainHost host, Language language, string panelArea = null) { _chain.Add(async liquidObject => { var liquidOrderApproval = await _mediator.Send(new GetOrderApprovalTokensCommand { - Approval = approval, Buyer = buyer, Store = store, Host = host, Language = language + Approval = approval, Buyer = buyer, Store = store, Host = host, Language = language, + PanelArea = panelArea }); liquidObject.OrderApproval = liquidOrderApproval; await _mediator.EntityTokensAdded(approval, liquidOrderApproval, liquidObject); diff --git a/src/Business/Grand.Business.Messages/DotLiquidDrops/LiquidOrderApproval.cs b/src/Business/Grand.Business.Messages/DotLiquidDrops/LiquidOrderApproval.cs index 308d1e626..2803e491a 100644 --- a/src/Business/Grand.Business.Messages/DotLiquidDrops/LiquidOrderApproval.cs +++ b/src/Business/Grand.Business.Messages/DotLiquidDrops/LiquidOrderApproval.cs @@ -48,5 +48,15 @@ public LiquidOrderApproval() ///
public string OrderUrl { get; set; } + /// + /// Why the order needs an approval, as an HTML list; every value is HTML-encoded. Empty when there is no reason. + /// + public string ReasonsHtml { get; set; } + + /// + /// Absolute link to the order in the back office panel the recipient works in; empty for a message to the buyer + /// + public string PanelUrl { 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 7a15f39f0..602c067af 100644 --- a/src/Business/Grand.Business.Messages/Services/CustomerMessageProviderService.cs +++ b/src/Business/Grand.Business.Messages/Services/CustomerMessageProviderService.cs @@ -88,7 +88,8 @@ protected virtual async Task SendCustomerMessage(Customer customer, Store s /// 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) + Customer recipient, Store store, string languageId, string templateName, DomainHost host, + string toEmail = null) { ArgumentNullException.ThrowIfNull(approval); ArgumentNullException.ThrowIfNull(buyer); @@ -113,7 +114,7 @@ protected virtual async Task SendOrderApprovalMessage(OrderApproval approva return await _messageProviderService.SendNotification(messageTemplate, emailAccount, languageId, liquidObject, - recipient.Email, recipient.GetFullName(), reference: Reference.Customer, objectId: buyer.Id); + toEmail ?? recipient.Email, recipient.GetFullName(), reference: Reference.Customer, objectId: buyer.Id); } /// @@ -151,6 +152,63 @@ public virtual async Task SendOrderApprovalDecidedMessage(OrderApproval app MessageTemplateNames.OrderApprovalDecided, host); } + /// + /// Sends an 'Order approval decided' message to the given address, for a buyer who has none of their own + /// + /// Order approval + /// Customer who asked for the approval + /// 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 + /// + /// Recipient + public virtual async Task SendOrderApprovalDecidedMessage(OrderApproval approval, Customer buyer, + Store store, string languageId, DomainHost host, string toEmail) + { + return await SendOrderApprovalMessage(approval, buyer, buyer, store, languageId, + MessageTemplateNames.OrderApprovalDecided, host, toEmail); + } + + /// + /// Sends an 'Order approval requested' message to the store, when the store decides the request + /// + /// Order approval + /// Customer who placed the order + /// Store + /// Recipient; empty sends it to the e-mail account of the message template + /// Recipient name + /// The back office panel the recipient works in + /// Message language identifier + public virtual async Task SendOrderApprovalStaffMessage(OrderApproval approval, Customer buyer, Store store, + string toEmail, string toName, string panelArea, string languageId) + { + ArgumentNullException.ThrowIfNull(approval); + ArgumentNullException.ThrowIfNull(buyer); + + var language = await _messageLookupService.EnsureLanguageIsActive(languageId, store.Id); + + var messageTemplate = await _messageLookupService.GetMessageTemplate( + MessageTemplateNames.OrderApprovalStaffRequested, store.Id); + if (messageTemplate == null) + return 0; + + var emailAccount = await _messageLookupService.GetEmailAccountOfMessageTemplate(messageTemplate, language.Id); + + var builder = new LiquidObjectBuilder(_mediator); + builder.AddStoreTokens(store, language, emailAccount) + .AddOrderApprovalTokens(approval, buyer, store, store.LinkHost(null), language, panelArea); + + var liquidObject = await builder.BuildAsync(); + await _mediator.MessageTokensAdded(messageTemplate, liquidObject); + + var hasRecipient = !string.IsNullOrWhiteSpace(toEmail); + return await _messageProviderService.SendNotification(messageTemplate, emailAccount, + languageId, liquidObject, + hasRecipient ? toEmail : emailAccount.Email, hasRecipient ? toName : emailAccount.DisplayName, + reference: Reference.Order, objectId: approval.OrderId); + } + /// /// 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 73fa600ac..688e31fbc 100644 --- a/src/Business/Grand.Business.Messages/Services/MessageTemplateNames.cs +++ b/src/Business/Grand.Business.Messages/Services/MessageTemplateNames.cs @@ -11,6 +11,7 @@ public static class MessageTemplateNames public const string OrderApprovalRequested = "OrderApproval.Requested"; public const string OrderApprovalDecided = "OrderApproval.Decided"; + public const string OrderApprovalStaffRequested = "OrderApproval.StaffRequested"; public const string SendOrderPlacedStoreOwnerMessage = "OrderPlaced.StoreOwnerNotification"; public const string SendOrderPaidStoreOwnerMessage = "OrderPaid.StoreOwnerNotification"; diff --git a/src/Core/Grand.Domain/Orders/OrderApproval.cs b/src/Core/Grand.Domain/Orders/OrderApproval.cs index a81747092..2c9b002cf 100644 --- a/src/Core/Grand.Domain/Orders/OrderApproval.cs +++ b/src/Core/Grand.Domain/Orders/OrderApproval.cs @@ -16,11 +16,21 @@ public class OrderApproval : BaseEntity public string StoreId { get; set; } /// - /// Gets or sets the identifier of the company owner; every read is scoped by it + /// Gets or sets who decides the request + /// + public OrderApprovalDecider Decider { get; set; } + + /// + /// Gets or sets the identifier of the company owner; every company read is scoped by it. Null when the store decides. /// [AuditReference(typeof(Customer))] public string CompanyOwnerId { get; set; } + /// + /// Gets or sets why the order needs an approval, in the buyer's language + /// + public IList Reasons { get; set; } = new List(); + /// /// Gets or sets the buyer identifier /// @@ -68,6 +78,11 @@ public class OrderApproval : BaseEntity /// public string DecisionComment { get; set; } + /// + /// Gets or sets the identifier of the write that closed the request; tells concurrent callers which one won + /// + public string CloseId { get; set; } + /// /// Gets or sets the identifier of the order awaiting the decision; set at creation /// diff --git a/src/Core/Grand.Domain/Orders/OrderApprovalDecider.cs b/src/Core/Grand.Domain/Orders/OrderApprovalDecider.cs new file mode 100644 index 000000000..d1156c509 --- /dev/null +++ b/src/Core/Grand.Domain/Orders/OrderApprovalDecider.cs @@ -0,0 +1,17 @@ +namespace Grand.Domain.Orders; + +/// +/// Who decides an order approval +/// +public enum OrderApprovalDecider +{ + /// + /// The buyer's company, on the storefront + /// + Company = 0, + + /// + /// The store, in the back office + /// + Store = 1 +} diff --git a/src/Core/Grand.Domain/Stores/Store.cs b/src/Core/Grand.Domain/Stores/Store.cs index 43389189e..472a19e9d 100644 --- a/src/Core/Grand.Domain/Stores/Store.cs +++ b/src/Core/Grand.Domain/Stores/Store.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Customers; using Grand.Domain.Shipping; using Grand.Domain.Directory; using Grand.Domain.Audit; @@ -25,6 +26,12 @@ public class Store : BaseEntity, ITranslationEntity /// public string Url { get; set; } + /// + /// Gets or sets the account told about the store's order approvals; it does not limit who may decide them + /// + [AuditReference(typeof(Customer))] + public string OwnerCustomerId { get; set; } + /// /// Gets or sets the list of domains /// diff --git a/src/Modules/Grand.Module.Installer/Services/MessageTemplates/MessageTemplateSeed.OrderApproval.cs b/src/Modules/Grand.Module.Installer/Services/MessageTemplates/MessageTemplateSeed.OrderApproval.cs index 653897187..45ce00fff 100644 --- a/src/Modules/Grand.Module.Installer/Services/MessageTemplates/MessageTemplateSeed.OrderApproval.cs +++ b/src/Modules/Grand.Module.Installer/Services/MessageTemplates/MessageTemplateSeed.OrderApproval.cs @@ -30,7 +30,7 @@ private static IEnumerable OrderApprovalTemplates(string ea) "Your order approval request has been decided.", "Order approval {{OrderApproval.Status}}", @" -

Hello {{Customer.FullName | Escape}},

+

Hello {{OrderApproval.BuyerName | 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:

@@ -43,6 +43,25 @@ private static IEnumerable OrderApprovalTemplates(string ea)

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

"), IsActive = true, EmailAccountId = ea + }, + new() { + Name = "OrderApproval.StaffRequested", + Subject = "{{Store.Name}}: order #{{OrderApproval.OrderNumber}} needs your decision", + Body = Layout( + "Order #{{OrderApproval.OrderNumber}} is waiting for a decision.", + "Order approval needed", + @" +

{{OrderApproval.BuyerName | Escape}} ({{OrderApproval.BuyerEmail | Escape}}) placed order #{{OrderApproval.OrderNumber}} at {{Store.Name}}. The order is on hold until someone in the store decides.

+ {% if OrderApproval.ReasonsHtml != '' %} +

Why it needs a decision:

+ {{OrderApproval.ReasonsHtml}} + {% endif %}" + + Button("{{OrderApproval.PanelUrl}}", "Review the order") + @" +

The order contains:

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

Order total: {{OrderApproval.Total}}

"), + IsActive = true, + EmailAccountId = ea } }; } diff --git a/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationOrderApproval.cs b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationOrderApproval.cs index 48ec61e9e..2af64a9e6 100644 --- a/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationOrderApproval.cs +++ b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationOrderApproval.cs @@ -121,7 +121,7 @@ await repository.InsertAsync(SettingExtensions.CreateSetting(new OrderApprovalSe /// 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) + internal static async Task GetEmailAccountId(IServiceProvider serviceProvider) { var emailAccountRepository = serviceProvider.GetRequiredService>(); var settingRepository = serviceProvider.GetRequiredService>(); @@ -172,7 +172,7 @@ public static IEnumerable Templates(string emailAccountId) "Your order approval request has been decided.", "Order approval {{OrderApproval.Status}}", @" -

Hello {{Customer.FullName | Escape}},

+

Hello {{OrderApproval.BuyerName | 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:

@@ -188,7 +188,7 @@ public static IEnumerable Templates(string emailAccountId) }; } - private static string Layout(string preheader, string heading, string content) + internal static string Layout(string preheader, string heading, string content) { return $@" @@ -242,7 +242,7 @@ private static string Layout(string preheader, string heading, string content) "; } - private static string Button(string url, string text) + internal static string Button(string url, string text) { return $@" diff --git a/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationOrderApprovalStaffTemplate.cs b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationOrderApprovalStaffTemplate.cs new file mode 100644 index 000000000..d8809ae97 --- /dev/null +++ b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationOrderApprovalStaffTemplate.cs @@ -0,0 +1,77 @@ +using Grand.Data; +using Grand.Domain.Messages; +using Grand.Infrastructure.Caching; +using Grand.Infrastructure.Caching.Constants; +using Grand.Infrastructure.Migrations; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Grand.Module.Migration.Migrations._2._5; + +/// +/// Adds the e-mail telling the store it has an order approval to decide. The installer creates the same for new +/// installations (it is not referenced from here, so the body is repeated below - a test keeps them equal). +/// Idempotent: a template that exists (the operator may have created or edited one) is never touched. +/// +public class MigrationOrderApprovalStaffTemplate : IMigration +{ + private const string TemplateName = "OrderApproval.StaffRequested"; + + public int Priority => 2; + public DbVersion Version => new(2, 5); + public Guid Identity => new("4e7d9a26-1c3b-4f8e-a5d0-6b92c7e3f118"); + public string Name => "Order approval e-mail to the store 2.5"; + + public async Task UpgradeProcess(IServiceProvider serviceProvider) + { + var logService = serviceProvider.GetRequiredService>(); + + try + { + var repository = serviceProvider.GetRequiredService>(); + var existing = await repository.FirstOrDefaultAsync(repository.Table.Where(x => x.Name == TemplateName)); + if (existing != null) + return true; + + await repository.InsertAsync(Template(await MigrationOrderApproval.GetEmailAccountId(serviceProvider))); + + //a lookup made before the upgrade may have cached "no such template" + var cache = serviceProvider.GetService(); + if (cache != null) + await cache.RemoveByPrefix(CacheKey.MESSAGETEMPLATES_PATTERN_KEY); + } + catch (Exception ex) + { + logService.LogError(ex, "UpgradeProcess - OrderApprovalStaffTemplate (2.5)"); + return false; + } + + return true; + } + + /// + /// The template, as the installer seeds it: same name, subject and tokens + /// + public static MessageTemplate Template(string emailAccountId) + { + return new MessageTemplate { + Name = TemplateName, + Subject = "{{Store.Name}}: order #{{OrderApproval.OrderNumber}} needs your decision", + Body = MigrationOrderApproval.Layout( + "Order #{{OrderApproval.OrderNumber}} is waiting for a decision.", + "Order approval needed", + @" +

{{OrderApproval.BuyerName | Escape}} ({{OrderApproval.BuyerEmail | Escape}}) placed order #{{OrderApproval.OrderNumber}} at {{Store.Name}}. The order is on hold until someone in the store decides.

+{% if OrderApproval.ReasonsHtml != '' %} +

Why it needs a decision:

+{{OrderApproval.ReasonsHtml}} +{% endif %}" + + MigrationOrderApproval.Button("{{OrderApproval.PanelUrl}}", "Review the order") + @" +

The order contains:

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

Order total: {{OrderApproval.Total}}

"), + IsActive = true, + EmailAccountId = emailAccountId + }; + } +} diff --git a/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/OrderApprovalDecisionCommandsTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/OrderApprovalDecisionCommandsTests.cs new file mode 100644 index 000000000..db28c6385 --- /dev/null +++ b/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/OrderApprovalDecisionCommandsTests.cs @@ -0,0 +1,260 @@ +using Grand.Business.Checkout.Commands.Handlers.Orders; +using Grand.Business.Core.Commands.Checkout.Orders; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.Messages; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Domain.Localization; +using Grand.Domain.Orders; +using Grand.Domain.Stores; +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 OrderApprovalDecisionCommandsTests +{ + private readonly Customer _buyer = new() { Id = "buyer", Email = "buyer@example.com" }; + private readonly Customer _staff = new() { Id = "staff", Email = "staff@example.com" }; + + private ApplyOrderApprovalDecisionCommandHandler _apply; + private OrderApproval _approval; + private Mock _approvals; + private DecideStoreOrderApprovalCommandHandler _decideStore; + private Mock _mediator; + private Mock _messages; + private Order _order; + private Mock _orders; + + [TestInitialize] + public void Init() + { + _order = new Order { + Id = "o1", CustomerId = "buyer", StoreId = "s1", OrderStatusId = (int)OrderStatusSystem.AwaitingApproval + }; + _approval = new OrderApproval { + Id = "a1", OrderId = "o1", CustomerId = "buyer", StoreId = "s1", Status = OrderApprovalStatus.Pending, + Decider = OrderApprovalDecider.Store + }; + _approvals = new Mock(); + _approvals.Setup(x => x.GetByOrderId("o1")).ReturnsAsync(_approval); + _approvals.Setup(x => x.Close(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync((OrderApproval a, OrderApprovalStatus status, string decidedBy, string comment) => + { + a.Status = status; + a.DecidedByCustomerId = decidedBy; + a.DecisionComment = comment; + return true; + }); + _orders = new Mock(); + _orders.Setup(x => x.GetOrderById("o1")).ReturnsAsync(_order); + var customers = new Mock(); + customers.Setup(x => x.GetCustomerById("buyer")).ReturnsAsync(_buyer); + var stores = new Mock(); + stores.Setup(x => x.GetStoreById("s1")).ReturnsAsync(new Store { Id = "s1" }); + _messages = new Mock(); + _mediator = new Mock(); + + _apply = new ApplyOrderApprovalDecisionCommandHandler(_approvals.Object, customers.Object, _messages.Object, + _orders.Object, stores.Object, _mediator.Object, + new Mock>().Object); + _mediator.Setup(x => x.Send(It.IsAny(), It.IsAny())) + .Returns((IRequest command, CancellationToken token) => + _apply.Handle((ApplyOrderApprovalDecisionCommand)command, token)); + _decideStore = new DecideStoreOrderApprovalCommandHandler(_approvals.Object, _mediator.Object); + } + + private ApplyOrderApprovalDecisionCommand Apply(bool approve = true, Customer decidedBy = null) + { + return new ApplyOrderApprovalDecisionCommand { + Approval = _approval, DecidedBy = decidedBy ?? _staff, Approve = approve, Comment = " note ", + Language = new Language { Id = "en" } + }; + } + + private DecideStoreOrderApprovalCommand DecideStore(Customer decidedBy = null) + { + return new DecideStoreOrderApprovalCommand { + Order = _order, DecidedBy = decidedBy ?? _staff, Approve = true, Language = new Language { Id = "en" } + }; + } + + private void NothingWasTouched() + { + _approvals.Verify(x => x.Close(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + OrderWasNotMoved(); + } + + private void OrderWasNotMoved() + { + _mediator.Verify(x => x.Send(It.IsAny(), It.IsAny()), Times.Never); + _mediator.Verify(x => x.Send(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Apply_Approve_MovesTheOrderAndRecordsTheDecision() + { + var result = await _apply.Handle(Apply(), default); + + Assert.IsTrue(result); + Assert.AreEqual(OrderApprovalStatus.Approved, _approval.Status); + Assert.AreEqual("staff", _approval.DecidedByCustomerId); + Assert.AreEqual("note", _approval.DecisionComment); + _mediator.Verify(x => x.Send(It.Is(c => c.Os == OrderStatusSystem.Pending), + It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task Apply_AnotherDecisionWonTheRace_MovesNothingAndSendsNothing() + { + _approvals.Setup(x => x.Close(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).ReturnsAsync(false); + + Assert.IsFalse(await _apply.Handle(Apply(), default)); + + OrderWasNotMoved(); + _orders.Verify(x => x.InsertOrderNote(It.IsAny()), Times.Never); + _messages.Verify(x => x.SendOrderApprovalDecidedMessage(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Apply_SavingTheDecisionFails_OrderStaysAwaiting() + { + //a failed save must not leave a moved order whose request can never be decided again + _approvals.Setup(x => x.Close(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).ThrowsAsync(new InvalidOperationException("db down")); + + await Assert.ThrowsExactlyAsync(() => _apply.Handle(Apply(), default)); + + OrderWasNotMoved(); + } + + [TestMethod] + public async Task Apply_MovingTheOrderFails_ReopensTheRequest() + { + _mediator.Setup(x => x.Send(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("boom")); + + Assert.IsFalse(await _apply.Handle(Apply(), default)); + + _approvals.Verify(x => x.Reopen(_approval), Times.Once); + _messages.Verify(x => x.SendOrderApprovalDecidedMessage(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Apply_NotPending_ReturnsFalseAndTouchesNothing() + { + _approval.Status = OrderApprovalStatus.Withdrawn; + + Assert.IsFalse(await _apply.Handle(Apply(), default)); + + NothingWasTouched(); + } + + [TestMethod] + public async Task Apply_OrderNotAwaiting_ReturnsFalse() + { + _order.OrderStatusId = (int)OrderStatusSystem.Cancelled; + + Assert.IsFalse(await _apply.Handle(Apply(), default)); + + NothingWasTouched(); + } + + [TestMethod] + public async Task Apply_DeciderIsBuyer_ReturnsFalse() + { + Assert.IsFalse(await _apply.Handle(Apply(decidedBy: _buyer), default)); + + NothingWasTouched(); + } + + [TestMethod] + public async Task Apply_Twice_SecondCallSendsNoSecondMailOrMove() + { + Assert.IsTrue(await _apply.Handle(Apply(), default)); + Assert.IsFalse(await _apply.Handle(Apply(), default)); + + _mediator.Verify(x => x.Send(It.IsAny(), It.IsAny()), Times.Once); + _messages.Verify(x => x.SendOrderApprovalDecidedMessage(_approval, _buyer, It.IsAny(), + It.IsAny(), It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task Apply_GuestBuyer_SendsDecisionToTheBillingEmail() + { + _buyer.Email = null; + _order.BillingAddress = new Address { Email = "guest@example.com" }; + + await _apply.Handle(Apply(false), default); + + _messages.Verify(x => x.SendOrderApprovalDecidedMessage(_approval, _buyer, It.IsAny(), + It.IsAny(), It.IsAny(), "guest@example.com"), Times.Once); + } + + [TestMethod] + public async Task Apply_Approve_WritesNoteNamingTheDecider() + { + await _apply.Handle(Apply(), default); + + _orders.Verify(x => x.InsertOrderNote(It.Is(n => + n.OrderId == "o1" && n.Note == "Order approved by staff@example.com" && !n.DisplayToCustomer)), Times.Once); + } + + [TestMethod] + public async Task Apply_Reject_WritesNoteNamingTheDecider() + { + await _apply.Handle(Apply(false), default); + + Assert.AreEqual(OrderApprovalStatus.Rejected, _approval.Status); + _mediator.Verify(x => x.Send(It.IsAny(), It.IsAny()), Times.Once); + _orders.Verify(x => x.InsertOrderNote(It.Is(n => + n.OrderId == "o1" && n.Note == "Order rejected by staff@example.com" && !n.DisplayToCustomer)), Times.Once); + } + + [TestMethod] + public async Task DecideStore_NoRequest_False() + { + _approvals.Setup(x => x.GetByOrderId("o1")).ReturnsAsync((OrderApproval)null); + + Assert.IsFalse(await _decideStore.Handle(DecideStore(), default)); + + NothingWasTouched(); + } + + [TestMethod] + public async Task DecideStore_CompanyDecided_False() + { + _approval.Decider = OrderApprovalDecider.Company; + + Assert.IsFalse(await _decideStore.Handle(DecideStore(), default)); + + NothingWasTouched(); + } + + [TestMethod] + public async Task DecideStore_StoreDecided_DelegatesToApply() + { + Assert.IsTrue(await _decideStore.Handle(DecideStore(), default)); + + Assert.AreEqual(OrderApprovalStatus.Approved, _approval.Status); + Assert.AreEqual("staff", _approval.DecidedByCustomerId); + } + + [TestMethod] + public async Task DecideStore_OwnOrder_False() + { + Assert.IsFalse(await _decideStore.Handle(DecideStore(_buyer), default)); + + NothingWasTouched(); + } +} 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 index 63982eb4f..ab78d764f 100644 --- a/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/PlaceOrderCommandHandlerApprovalTests.cs +++ b/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/PlaceOrderCommandHandlerApprovalTests.cs @@ -1,6 +1,7 @@ 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.Events.Checkout.Orders; using Grand.Business.Core.Interfaces.Catalog.Discounts; using Grand.Business.Core.Interfaces.Catalog.Prices; using Grand.Business.Core.Interfaces.Catalog.Products; @@ -39,11 +40,13 @@ public class PlaceOrderCommandHandlerApprovalTests private PlaceOrderContainer _details; private TestHandler _handler; private Mock _mediator; + private Mock _placementService; [TestInitialize] public void Init() { _approvalService = new Mock(); + _placementService = new Mock(); _mediator = new Mock(); var translation = new Mock(); translation.Setup(x => x.GetResource("Checkout.OrderApproval.PaymentMethodNotAllowed", "pl", It.IsAny(), @@ -69,13 +72,118 @@ public void Init() OrderTotal = 500 }; - _handler = new TestHandler(_details, _approvalService, _mediator, translation); + _handler = new TestHandler(_details, _approvalService, _placementService, _mediator, translation); } - private void Requires(bool required) + private void Evaluates(OrderPlacementDecision decision, string paymentMethod, params string[] reasons) { - _approvalService.Setup(x => x.RequiresApproval(_details.Cart, _command.Context)) - .ReturnsAsync(new OrderApprovalCheckoutState(required, 100, 500)); + _placementService.Setup(x => x.Evaluate(_details.Cart, _command.Context, _command.Language, paymentMethod, + It.IsAny())) + .ReturnsAsync(new OrderPlacementEvaluation(decision, reasons)); + } + + private void Requires(bool required, string paymentMethod = "Payments.Card") + { + Evaluates(required ? OrderPlacementDecision.RequireApproval : OrderPlacementDecision.Allow, paymentMethod, + required ? ["over the limit"] : []); + } + + [TestMethod] + public async Task Handle_Blocked_AddsEveryReasonAndSkipsPayment() + { + Evaluates(OrderPlacementDecision.Block, "Payments.Card", "blocked customer", "banned product"); + + var result = await _handler.Handle(_command, CancellationToken.None); + + CollectionAssert.AreEqual(new[] { "blocked customer", "banned product" }, result.Errors.ToArray()); + 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_PassesItsTokenToTheRules() + { + using var source = new CancellationTokenSource(); + Evaluates(OrderPlacementDecision.Block, "Payments.Card", "blocked customer"); + + await _handler.Handle(_command, source.Token); + + _placementService.Verify(x => x.Evaluate(_details.Cart, _command.Context, _command.Language, "Payments.Card", + source.Token), Times.Once); + } + + [TestMethod] + public async Task Handle_RuleWantsTheStoreToDecide_SaysSoOnOrderPlacedEvent() + { + _placementService.Setup(x => x.Evaluate(_details.Cart, _command.Context, _command.Language, "Payments.Card", + It.IsAny())) + .ReturnsAsync(new OrderPlacementEvaluation(OrderPlacementDecision.RequireApproval, ["restricted goods"], + true)); + _approvalService.Setup(x => x.IsPaymentMethodAllowed("Payments.Card")).Returns(true); + OrderPlacedEvent published = null; + _mediator.Setup(x => x.Publish(It.IsAny(), It.IsAny())) + .Callback((e, _) => published = e) + .Returns(Task.CompletedTask); + + await _handler.Handle(_command, CancellationToken.None); + + Assert.IsTrue(published.ApprovalStoreDecides); + } + + [TestMethod] + public async Task Handle_AllowedEarlierButBlockedAtPlacement_SkipsPayment() + { + //what the buyer was shown earlier, before a payment method was known + Evaluates(OrderPlacementDecision.Allow, null); + Evaluates(OrderPlacementDecision.Block, "Payments.Card", "not with this method"); + + var result = await _handler.Handle(_command, CancellationToken.None); + + CollectionAssert.AreEqual(new[] { "not with this method" }, result.Errors.ToArray()); + Assert.AreEqual(0, _handler.PaymentCalls); + } + + [TestMethod] + public async Task Handle_RequiresApprovalWithDisallowedMethodAtPlacement_ReturnsErrorWithoutPayment() + { + Evaluates(OrderPlacementDecision.Allow, null); + Evaluates(OrderPlacementDecision.RequireApproval, "Payments.Card", "this method needs approval"); + _approvalService.Setup(x => x.IsPaymentMethodAllowed("Payments.Card")).Returns(false); + + var result = await _handler.Handle(_command, CancellationToken.None); + + CollectionAssert.Contains(result.Errors.ToList(), NotAllowedText); + Assert.AreEqual(0, _handler.PaymentCalls); + } + + [TestMethod] + public async Task Handle_RequiresApprovalWithNothingToPay_PlacesWithoutAPaymentMethod() + { + //loyalty points or a gift voucher cover the order: the checkout skips the payment step and clears the method + _details.OrderTotal = 0; + _details.PaymentMethodSystemName = null; + Requires(true, null); + + var result = await _handler.Handle(_command, CancellationToken.None); + + Assert.IsEmpty(result.Errors); + Assert.AreEqual((int)OrderStatusSystem.AwaitingApproval, _handler.LastHeader.OrderStatusId); + } + + [TestMethod] + public async Task Handle_ApprovalRequired_PublishesReasonsOnOrderPlacedEvent() + { + Evaluates(OrderPlacementDecision.RequireApproval, "Payments.Card", "over the limit", "restricted goods"); + _approvalService.Setup(x => x.IsPaymentMethodAllowed("Payments.Card")).Returns(true); + OrderPlacedEvent published = null; + _mediator.Setup(x => x.Publish(It.IsAny(), It.IsAny())) + .Callback((e, _) => published = e) + .Returns(Task.CompletedTask); + + await _handler.Handle(_command, CancellationToken.None); + + CollectionAssert.AreEqual(new[] { "over the limit", "restricted goods" }, published.ApprovalReasons.ToArray()); } [TestMethod] @@ -95,7 +203,7 @@ public async Task Handle_ApprovalRequiredAndMethodNotAllowed_AddsErrorAndSkipsPa [TestMethod] public async Task Handle_ApprovalRequiredAndMethodAllowed_PlacesAwaitingOrderWithoutStoreOwnerAndVendorMails() { - Requires(true); + Requires(true, "Payments.CashOnDelivery"); _approvalService.Setup(x => x.IsPaymentMethodAllowed("Payments.CashOnDelivery")).Returns(true); _details.PaymentMethodSystemName = "Payments.CashOnDelivery"; OrderNotificationCommand sent = null; @@ -154,7 +262,8 @@ private class TestHandler : PlaceOrderCommandHandler { private readonly PlaceOrderContainer _details; - public TestHandler(PlaceOrderContainer details, Mock approval, Mock mediator, + public TestHandler(PlaceOrderContainer details, Mock approval, + Mock placement, Mock mediator, Mock translation) : base(new Mock().Object, new Mock().Object, new Mock().Object, new Mock().Object, new Mock().Object, @@ -169,7 +278,7 @@ public TestHandler(PlaceOrderContainer details, Mock appr 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(), + placement.Object, translation.Object, new ShippingSettings(), new ShoppingCartSettings(), new PaymentSettings(), new OrderSettings(), new OidcLoginConfig(), new TaxSettings()) { _details = details; diff --git a/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/PlaceOrderCommandHandlerPurchaseOrderTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/PlaceOrderCommandHandlerPurchaseOrderTests.cs index be15d049e..5dc8b9199 100644 --- a/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/PlaceOrderCommandHandlerPurchaseOrderTests.cs +++ b/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/PlaceOrderCommandHandlerPurchaseOrderTests.cs @@ -41,7 +41,7 @@ public class PlaceOrderCommandHandlerPurchaseOrderTests [TestInitialize] public void Init() { - var approvalService = new Mock(); + var placementService = new Mock(); var translation = new Mock(); translation.Setup(x => x.GetResource("Checkout.PurchaseOrderNumber.Required", "pl", It.IsAny(), It.IsAny())).Returns(RequiredText); @@ -65,13 +65,14 @@ public void Init() PaymentMethodSystemName = "Payments.Card", OrderTotal = 500 }; - approvalService.Setup(x => x.RequiresApproval(_details.Cart, _command.Context)) - .ReturnsAsync(new OrderApprovalCheckoutState(false, null, 500)); + placementService.Setup(x => x.Evaluate(_details.Cart, _command.Context, _command.Language, It.IsAny(), + It.IsAny())) + .ReturnsAsync(new OrderPlacementEvaluation(OrderPlacementDecision.Allow, [])); _provider = new Mock(); var paymentService = new Mock(); paymentService.Setup(x => x.LoadPaymentMethodBySystemName("Payments.Card")).Returns(_provider.Object); - _handler = new TestHandler(_details, approvalService, new Mock(), translation, paymentService); + _handler = new TestHandler(_details, placementService, new Mock(), translation, paymentService); } [TestMethod] @@ -147,7 +148,7 @@ private class TestHandler : PlaceOrderCommandHandler { private readonly PlaceOrderContainer _details; - public TestHandler(PlaceOrderContainer details, Mock approval, Mock mediator, + public TestHandler(PlaceOrderContainer details, Mock placement, Mock mediator, Mock translation, Mock paymentService) : base(new Mock().Object, new Mock().Object, new Mock().Object, new Mock().Object, paymentService.Object, @@ -161,7 +162,8 @@ public TestHandler(PlaceOrderContainer details, Mock appr 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, + new Mock().Object, new Mock().Object, + new Mock().Object, placement.Object, translation.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/PlaceOrderCommandHandlerTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Commands/Handlers/Orders/PlaceOrderCommandHandlerTests.cs index 503de3016..b0df2e058 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 @@ -57,7 +57,7 @@ public void Init() 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 ShippingSettings(), + 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/Events/Orders/OrderCancelledOrderApprovalHandlerTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Events/Orders/OrderCancelledOrderApprovalHandlerTests.cs index a5c5b3420..a07ef30e7 100644 --- a/src/Tests/Grand.Business.Checkout.Tests/Events/Orders/OrderCancelledOrderApprovalHandlerTests.cs +++ b/src/Tests/Grand.Business.Checkout.Tests/Events/Orders/OrderCancelledOrderApprovalHandlerTests.cs @@ -25,6 +25,12 @@ public void Init() _order = new Order { Id = "order", OrderNumber = 7, OrderStatusId = (int)OrderStatusSystem.Cancelled }; } + private void NothingWasClosed() + { + _approvalService.Verify(x => x.Close(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + [TestMethod] public async Task Handle_PendingApproval_ClosesItAsCancelled() { @@ -33,10 +39,7 @@ public async Task Handle_PendingApproval_ClosesItAsCancelled() 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); + _approvalService.Verify(x => x.Close(approval, OrderApprovalStatus.Cancelled, null, null), Times.Once); } [TestMethod] @@ -52,7 +55,7 @@ public async Task Handle_DecidedApproval_IsLeftAlone(OrderApprovalStatus status) await _handler.Handle(new OrderCancelledEvent(_order), CancellationToken.None); Assert.AreEqual(status, approval.Status); - _approvalService.Verify(x => x.Update(It.IsAny()), Times.Never); + NothingWasClosed(); } [TestMethod] @@ -62,7 +65,7 @@ public async Task Handle_OrderWithoutApproval_DoesNothing() await _handler.Handle(new OrderCancelledEvent(_order), CancellationToken.None); - _approvalService.Verify(x => x.Update(It.IsAny()), Times.Never); + NothingWasClosed(); } [TestMethod] @@ -70,7 +73,8 @@ 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")); + _approvalService.Setup(x => x.Close(It.IsAny(), OrderApprovalStatus.Cancelled, null, null)) + .ThrowsAsync(new Exception("db down")); await _handler.Handle(new OrderCancelledEvent(_order), CancellationToken.None); diff --git a/src/Tests/Grand.Business.Checkout.Tests/Events/Orders/OrderPlacedOrderApprovalDeciderTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Events/Orders/OrderPlacedOrderApprovalDeciderTests.cs new file mode 100644 index 000000000..acf657e98 --- /dev/null +++ b/src/Tests/Grand.Business.Checkout.Tests/Events/Orders/OrderPlacedOrderApprovalDeciderTests.cs @@ -0,0 +1,166 @@ +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.Customers; +using Grand.Domain.Directory; +using Grand.Domain.Localization; +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 OrderPlacedOrderApprovalDeciderTests +{ + private Mock _customerService; + private CustomerSettings _customerSettings; + private OrderPlacedOrderApprovalHandler _handler; + private OrderApproval _inserted; + private Mock _messages; + + [TestInitialize] + public void Init() + { + var approvalService = new Mock(); + approvalService.Setup(x => x.Insert(It.IsAny())).Callback(a => _inserted = a) + .Returns(Task.CompletedTask); + _customerService = new Mock(); + _customerService.Setup(x => x.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, int.MaxValue, 0)); + _customerService.Setup(x => x.GetCustomerById("owner")) + .ReturnsAsync(new Customer { Id = "owner", Email = "owner@acme.example", Active = true }); + _messages = new Mock(); + + var storeService = new Mock(); + storeService.Setup(x => x.GetStoreById("store")).ReturnsAsync(new Store { Id = "store" }); + var currencyService = new Mock(); + currencyService.Setup(x => x.GetCurrencyByCode("EUR")).ReturnsAsync(new Currency { CurrencyCode = "EUR" }); + + _customerSettings = new CustomerSettings { HideSubAccountsTab = false }; + _handler = new OrderPlacedOrderApprovalHandler(approvalService.Object, _customerService.Object, + _messages.Object, storeService.Object, new Mock().Object, + new Mock().Object, currencyService.Object, new Mock().Object, + new Mock().Object, _customerSettings, new LanguageSettings(), + new Mock>().Object); + } + + //the order's OwnerId holds the customer's own id when there is no owner, as PlaceOrderCommandHandler sets it + private OrderPlacedEvent Placed(Customer buyer) + { + _customerService.Setup(x => x.GetCustomerById(buyer.Id)).ReturnsAsync(buyer); + return new OrderPlacedEvent(new Order { + Id = "order", StoreId = "store", CustomerId = buyer.Id, CustomerCurrencyCode = "EUR", + OwnerId = string.IsNullOrEmpty(buyer.OwnerId) ? buyer.Id : buyer.OwnerId, + OrderStatusId = (int)OrderStatusSystem.AwaitingApproval + }); + } + + private void NoApproverIsAsked() + { + _messages.Verify(x => x.SendOrderApprovalRequestedMessage(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Handle_RetailBuyer_StoreDecidesWithoutCompany() + { + await _handler.Handle(Placed(new Customer { Id = "retail", Email = "r@x.example" }), CancellationToken.None); + + Assert.AreEqual(OrderApprovalDecider.Store, _inserted.Decider); + Assert.IsNull(_inserted.CompanyOwnerId); + NoApproverIsAsked(); + } + + [TestMethod] + public async Task Handle_CompanyOwnerBuyer_StoreDecides() + { + await _handler.Handle(Placed(new Customer { Id = "owner", Email = "owner@acme.example" }), CancellationToken.None); + + Assert.AreEqual(OrderApprovalDecider.Store, _inserted.Decider); + Assert.IsNull(_inserted.CompanyOwnerId); + } + + [TestMethod] + public async Task Handle_SubAccount_CompanyDecides() + { + var buyer = new Customer { Id = "buyer", OwnerId = "owner", Email = "b@acme.example" }; + + await _handler.Handle(Placed(buyer), CancellationToken.None); + + Assert.AreEqual(OrderApprovalDecider.Company, _inserted.Decider); + Assert.AreEqual("owner", _inserted.CompanyOwnerId); + _messages.Verify(x => x.SendOrderApprovalRequestedMessage(_inserted, buyer, + It.Is(c => c.Id == "owner"), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + } + + [TestMethod] + public async Task Handle_SubAccountWithFeatureOff_StoreDecides() + { + _customerSettings.HideSubAccountsTab = true; + + await _handler.Handle(Placed(new Customer { Id = "buyer", OwnerId = "owner", Email = "b@acme.example" }), + CancellationToken.None); + + Assert.AreEqual(OrderApprovalDecider.Store, _inserted.Decider); + Assert.IsNull(_inserted.CompanyOwnerId); + NoApproverIsAsked(); + } + + [TestMethod] + public async Task Handle_RuleWantsTheStoreToDecide_StoreDecidesASubAccountsOrder() + { + var placed = Placed(new Customer { Id = "buyer", OwnerId = "owner", Email = "b@acme.example" }); + placed.ApprovalStoreDecides = true; + + await _handler.Handle(placed, CancellationToken.None); + + Assert.AreEqual(OrderApprovalDecider.Store, _inserted.Decider); + Assert.IsNull(_inserted.CompanyOwnerId); + NoApproverIsAsked(); + } + + [TestMethod] + [DataRow(false, "owner@acme.example")] + [DataRow(true, " ")] + public async Task Handle_CompanyHasNobodyToAsk_StoreDecides(bool active, string email) + { + _customerService.Setup(x => x.GetCustomerById("owner")) + .ReturnsAsync(new Customer { Id = "owner", Email = email, Active = active }); + + await _handler.Handle(Placed(new Customer { Id = "buyer", OwnerId = "owner", Email = "b@acme.example" }), + CancellationToken.None); + + Assert.AreEqual(OrderApprovalDecider.Store, _inserted.Decider); + Assert.IsNull(_inserted.CompanyOwnerId); + _messages.Verify(x => x.SendOrderApprovalStaffMessage(_inserted, It.IsAny(), It.IsAny(), + null, null, "Admin", It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task Handle_CopiesReasonsFromEvent() + { + var placed = Placed(new Customer { Id = "retail", Email = "r@x.example" }); + placed.ApprovalReasons = ["over the limit", "restricted goods"]; + + await _handler.Handle(placed, CancellationToken.None); + + CollectionAssert.AreEqual(new[] { "over the limit", "restricted goods" }, _inserted.Reasons.ToArray()); + } +} diff --git a/src/Tests/Grand.Business.Checkout.Tests/Events/Orders/OrderPlacedOrderApprovalHandlerTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Events/Orders/OrderPlacedOrderApprovalHandlerTests.cs index 27441f645..84cc6a850 100644 --- a/src/Tests/Grand.Business.Checkout.Tests/Events/Orders/OrderPlacedOrderApprovalHandlerTests.cs +++ b/src/Tests/Grand.Business.Checkout.Tests/Events/Orders/OrderPlacedOrderApprovalHandlerTests.cs @@ -11,6 +11,7 @@ using Grand.Domain.Catalog; using Grand.Domain.Customers; using Grand.Domain.Directory; +using Grand.Domain.Localization; using Grand.Domain.Orders; using Grand.Domain.Stores; using Microsoft.Extensions.Logging; @@ -60,7 +61,8 @@ public void Init() _logger = new Mock>(); _handler = new OrderPlacedOrderApprovalHandler(_approvalService.Object, _customerService.Object, _messages.Object, storeService.Object, productService.Object, priceFormatter.Object, - currencyService.Object, _logger.Object); + currencyService.Object, new Mock().Object, new Mock().Object, + new CustomerSettings { HideSubAccountsTab = false }, new LanguageSettings(), _logger.Object); _owner = new Customer { Id = "owner", Email = "owner@acme.example", Active = true }; _buyer = new Customer { diff --git a/src/Tests/Grand.Business.Checkout.Tests/Events/Orders/OrderPlacedOrderApprovalStaffMailTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Events/Orders/OrderPlacedOrderApprovalStaffMailTests.cs new file mode 100644 index 000000000..4258c29d4 --- /dev/null +++ b/src/Tests/Grand.Business.Checkout.Tests/Events/Orders/OrderPlacedOrderApprovalStaffMailTests.cs @@ -0,0 +1,282 @@ +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.Customers; +using Grand.Domain.Directory; +using Grand.Domain.Localization; +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 OrderPlacedOrderApprovalStaffMailTests +{ + private readonly Customer _buyer = new() { Id = "retail", Email = "r@x.example" }; + private readonly Store _store = new() { Id = "store" }; + + private Mock _customerService; + private Mock _groups; + private OrderPlacedOrderApprovalHandler _handler; + private OrderApproval _inserted; + private Mock> _logger; + private Mock _messages; + private Order _order; + private Mock _salesEmployees; + + [TestInitialize] + public void Init() + { + var approvalService = new Mock(); + approvalService.Setup(x => x.Insert(It.IsAny())).Callback(a => _inserted = a) + .Returns(Task.CompletedTask); + _customerService = new Mock(); + _customerService.Setup(x => x.GetCustomerById("retail")).ReturnsAsync(_buyer); + _messages = new Mock(); + _salesEmployees = new Mock(); + _groups = new Mock(); + _logger = new Mock>(); + + var storeService = new Mock(); + storeService.Setup(x => x.GetStoreById("store")).ReturnsAsync(_store); + var currencyService = new Mock(); + currencyService.Setup(x => x.GetCurrencyByCode("EUR")).ReturnsAsync(new Currency { CurrencyCode = "EUR" }); + + _handler = new OrderPlacedOrderApprovalHandler(approvalService.Object, _customerService.Object, + _messages.Object, storeService.Object, new Mock().Object, + new Mock().Object, currencyService.Object, _salesEmployees.Object, _groups.Object, + new CustomerSettings { HideSubAccountsTab = false }, + new LanguageSettings { DefaultAdminLanguageId = "admin-lang" }, _logger.Object); + + _order = new Order { + Id = "order", StoreId = "store", CustomerId = "retail", OwnerId = "retail", CustomerCurrencyCode = "EUR", + OrderStatusId = (int)OrderStatusSystem.AwaitingApproval + }; + } + + private void SalesEmployee(bool active = true, string email = "sales@x.example") + { + _order.SeId = "se"; + _salesEmployees.Setup(x => x.GetSalesEmployeeById("se")).ReturnsAsync(new SalesEmployee { + Id = "se", Name = "Sales", Email = email, Active = active + }); + } + + private void Owner(bool active = true, bool deleted = false, string staffStoreId = null, bool admin = true, + bool manager = false) + { + var owner = new Customer { + Id = "store-owner", Email = "owner@x.example", Active = active, Deleted = deleted, + StaffStoreId = staffStoreId + }; + _store.OwnerCustomerId = owner.Id; + _customerService.Setup(x => x.GetCustomerById(owner.Id)).ReturnsAsync(owner); + _groups.Setup(x => x.IsAdmin(owner)).ReturnsAsync(admin); + _groups.Setup(x => x.IsStoreManager(owner)).ReturnsAsync(manager); + } + + private void Mailed(string toEmail, string panelArea) + { + _messages.Verify(x => x.SendOrderApprovalStaffMessage(_inserted, _buyer, _store, toEmail, It.IsAny(), + panelArea, "admin-lang"), Times.Once); + } + + private Task Place() + { + return _handler.Handle(new OrderPlacedEvent(_order), CancellationToken.None); + } + + [TestMethod] + public async Task Handle_StoreDecided_SalesEmployeeAndOwnerAccountGetTheMail() + { + //a sales employee record does not prove its person can open the panel, so the store is always told + SalesEmployee(); + Owner(); + + await Place(); + + Mailed("sales@x.example", "Admin"); + Mailed("owner@x.example", "Admin"); + } + + [TestMethod] + public async Task Handle_SalesEmployeeWithoutOwnerAccount_EmailAccountIsToldToo() + { + SalesEmployee(); + + await Place(); + + Mailed("sales@x.example", "Admin"); + Mailed(null, "Admin"); + } + + [TestMethod] + public async Task Handle_SalesEmployeeIsTheOwnerAccount_OneMail() + { + SalesEmployee(email: "OWNER@x.example"); + Owner(); + + await Place(); + + _messages.Verify(x => x.SendOrderApprovalStaffMessage(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + } + + [TestMethod] + public async Task Handle_MailToSalesEmployeeFails_OwnerAccountIsStillTold() + { + SalesEmployee(); + Owner(); + _messages.Setup(x => x.SendOrderApprovalStaffMessage(It.IsAny(), It.IsAny(), + It.IsAny(), "sales@x.example", It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("smtp")); + + await Place(); + + Mailed("owner@x.example", "Admin"); + } + + [TestMethod] + [DataRow(false, "sales@x.example")] + [DataRow(true, "")] + public async Task Handle_SalesEmployeeInactiveOrWithoutEmail_FallsToOwnerAccount(bool active, string email) + { + SalesEmployee(active, email); + Owner(); + + await Place(); + + Mailed("owner@x.example", "Admin"); + } + + [TestMethod] + [DataRow(false, false)] + [DataRow(true, true)] + public async Task Handle_OwnerAccountDeletedOrInactive_FallsToEmailAccount(bool active, bool deleted) + { + Owner(active, deleted); + + await Place(); + + Mailed(null, "Admin"); + } + + [TestMethod] + public async Task Handle_OwnerIsTheBuyer_FallsToEmailAccount() + { + //nobody decides their own order, so telling only them would leave the order with nobody aware of it + _buyer.Active = true; + _store.OwnerCustomerId = _buyer.Id; + + await Place(); + + Mailed(null, "Admin"); + } + + [TestMethod] + public async Task Handle_SalesEmployeeIsTheBuyer_FallsToOwnerAccount() + { + SalesEmployee(email: _buyer.Email); + Owner(); + + await Place(); + + Mailed("owner@x.example", "Admin"); + } + + [TestMethod] + public async Task Handle_NoSalesEmployeeAndNoOwner_FallsToEmailAccount() + { + await Place(); + + Mailed(null, "Admin"); + } + + [TestMethod] + public async Task Handle_OwnerWithStaffStore_LinksToStorePanel() + { + Owner(staffStoreId: "store", admin: false, manager: true); + + await Place(); + + Mailed("owner@x.example", "Store"); + } + + [TestMethod] + [DataRow(false, false, null, DisplayName = "no longer staff")] + [DataRow(false, true, "another-store", DisplayName = "moved to another store")] + [DataRow(false, false, "store", DisplayName = "manager group taken away")] + public async Task Handle_OwnerNoLongerWorksForThisStore_GetsNoCustomerData(bool admin, bool manager, + string staffStoreId) + { + //the account was valid when it was chosen; the mail carries the buyer's name, e-mail and order + Owner(staffStoreId: staffStoreId, admin: admin, manager: manager); + + await Place(); + + _messages.Verify(x => x.SendOrderApprovalStaffMessage(It.IsAny(), It.IsAny(), + It.IsAny(), "owner@x.example", It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + Mailed(null, "Admin"); + } + + [TestMethod] + public async Task Handle_AdministratorTiedToAnotherStore_LinksToAdminPanel() + { + //the Store panel would refuse this store's order + Owner(staffStoreId: "another-store"); + + await Place(); + + Mailed("owner@x.example", "Admin"); + } + + [TestMethod] + public async Task Handle_CompanyDecided_StoreIsNotMailed() + { + var buyer = new Customer { Id = "buyer", OwnerId = "owner", Email = "b@x.example" }; + _customerService.Setup(x => x.GetCustomerById("buyer")).ReturnsAsync(buyer); + _customerService.Setup(x => x.GetCustomerById("owner")) + .ReturnsAsync(new Customer { Id = "owner", Email = "owner@acme.example", Active = true }); + _customerService.Setup(x => x.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 Grand.Domain.PagedList([], 0, int.MaxValue, 0)); + _order.CustomerId = "buyer"; + _order.OwnerId = "owner"; + + await Place(); + + _messages.Verify(x => x.SendOrderApprovalStaffMessage(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task Handle_StaffMailFails_OrderEventStillCompletes() + { + _messages.Setup(x => x.SendOrderApprovalStaffMessage(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("smtp")); + + await Place(); + + Assert.IsNotNull(_inserted); + _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/Services/Orders/OrderApprovalLimitRuleProviderTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderApprovalLimitRuleProviderTests.cs new file mode 100644 index 000000000..ff0a20e64 --- /dev/null +++ b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderApprovalLimitRuleProviderTests.cs @@ -0,0 +1,143 @@ +using Grand.Business.Checkout.Services.Orders; +using Grand.Business.Core.Interfaces.Catalog.Prices; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Utilities.Checkout; +using Grand.Domain.Customers; +using Grand.Domain.Directory; +using Grand.Domain.Localization; +using Grand.Domain.Stores; +using Grand.Domain.Tax; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Business.Checkout.Tests.Services.Orders; + +[TestClass] +public class OrderApprovalLimitRuleProviderTests +{ + private readonly Currency _currency = new() { CurrencyCode = "PLN" }; + + private CustomerSettings _customerSettings; + private OrderApprovalLimitRuleProvider _rule; + private double? _total; + private int _totalCalls; + private Mock _translation; + + [TestInitialize] + public void Init() + { + _customerSettings = new CustomerSettings { HideSubAccountsTab = false }; + _total = 150; + _totalCalls = 0; + + var currencyService = new Mock(); + currencyService.Setup(x => x.ConvertFromPrimaryStoreCurrency(100, _currency)).ReturnsAsync(400); + var priceFormatter = new Mock(); + priceFormatter.Setup(x => x.FormatPrice(400, _currency)).Returns("400 zl"); + _translation = new Mock(); + Text("The order total exceeds your order limit of {0}."); + + _rule = new OrderApprovalLimitRuleProvider(_customerSettings, currencyService.Object, priceFormatter.Object, + _translation.Object); + } + + private void Text(string text) + { + _translation.Setup(x => x.GetResource("Checkout.OrderApproval.LimitExceeded", "pl", It.IsAny(), + It.IsAny())).Returns(text); + } + + private static Customer Buyer(double? limit = 100, SubAccountRole role = SubAccountRole.Buyer, + string ownerId = "owner1") + { + return new Customer { Id = "buyer1", OwnerId = ownerId, SubAccountRole = role, OrderApprovalLimit = limit }; + } + + private OrderPlacementContext Context(Customer customer) + { + var calculation = new CalculationContext(customer, new Store { Id = "s1" }, _currency, + TaxDisplayType.IncludingTax); + return new OrderPlacementContext([], calculation, new Language { Id = "pl" }, null, () => + { + _totalCalls++; + return Task.FromResult(_total); + }); + } + + [TestMethod] + public async Task Evaluate_RetailCustomer_NeverAsksForTotal() + { + var result = await _rule.Evaluate(Context(Buyer(100, ownerId: null))); + + Assert.AreEqual(OrderPlacementDecision.Allow, result.Decision); + Assert.AreEqual(0, _totalCalls); + } + + [TestMethod] + public async Task Evaluate_NoLimit_Allows() + { + var result = await _rule.Evaluate(Context(Buyer(null))); + + Assert.AreEqual(OrderPlacementDecision.Allow, result.Decision); + } + + [TestMethod] + public async Task Evaluate_Approver_Allows() + { + var result = await _rule.Evaluate(Context(Buyer(100, SubAccountRole.Approver))); + + Assert.AreEqual(OrderPlacementDecision.Allow, result.Decision); + } + + [TestMethod] + public async Task Evaluate_FeatureOff_Allows() + { + _customerSettings.HideSubAccountsTab = true; + + var result = await _rule.Evaluate(Context(Buyer())); + + Assert.AreEqual(OrderPlacementDecision.Allow, result.Decision); + Assert.AreEqual(0, _totalCalls); + } + + [TestMethod] + public async Task Evaluate_TotalUnknown_Allows() + { + _total = null; + + var result = await _rule.Evaluate(Context(Buyer())); + + Assert.AreEqual(OrderPlacementDecision.Allow, result.Decision); + } + + [TestMethod] + public async Task Evaluate_TotalAtLimit_Allows() + { + _total = 100; + + var result = await _rule.Evaluate(Context(Buyer())); + + Assert.AreEqual(OrderPlacementDecision.Allow, result.Decision); + } + + [TestMethod] + public async Task Evaluate_OverLimit_ReasonCarriesFormattedLimit() + { + var result = await _rule.Evaluate(Context(Buyer())); + + Assert.AreEqual(OrderPlacementDecision.RequireApproval, result.Decision); + Assert.AreEqual("The order total exceeds your order limit of 400 zl.", result.Reason); + } + + [TestMethod] + public async Task Evaluate_BrokenPlaceholderInResource_ReturnsTextUnformatted() + { + Text("Over the limit {oops"); + + var result = await _rule.Evaluate(Context(Buyer())); + + Assert.AreEqual(OrderPlacementDecision.RequireApproval, result.Decision); + Assert.AreEqual("Over the limit {oops", result.Reason); + } +} diff --git a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderApprovalServiceLiteDbTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderApprovalServiceLiteDbTests.cs new file mode 100644 index 000000000..17dee7940 --- /dev/null +++ b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderApprovalServiceLiteDbTests.cs @@ -0,0 +1,69 @@ +using Grand.Business.Checkout.Services.Orders; +using Grand.Data.Tests.LiteDb; +using Grand.Domain.Orders; +using Grand.Mediator; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Business.Checkout.Tests.Services.Orders; + +/// +/// Closing a request filters on its status: this holds on LiteDB too, or nobody there could decide anything +/// +[TestClass] +public class OrderApprovalServiceLiteDbTests +{ + private LiteDBRepositoryMock _repository; + private OrderApprovalService _service; + + [TestInitialize] + public void Init() + { + _repository = new LiteDBRepositoryMock(); + _service = new OrderApprovalService(_repository, new OrderApprovalSettings(), new Mock().Object); + } + + private async Task AddPending() + { + var approval = new OrderApproval { Status = OrderApprovalStatus.Pending, OrderId = "order1" }; + await _repository.InsertAsync(approval); + return approval; + } + + [TestMethod] + public async Task Close_Pending_Closes() + { + var approval = await AddPending(); + + Assert.IsTrue(await _service.Close(approval, OrderApprovalStatus.Approved, "staff")); + + var stored = await _repository.GetByIdAsync(approval.Id); + Assert.AreEqual(OrderApprovalStatus.Approved, stored.Status); + Assert.AreEqual("staff", stored.DecidedByCustomerId); + } + + [TestMethod] + public async Task Close_AlreadyClosed_Loses() + { + var approval = await AddPending(); + var first = await _repository.GetByIdAsync(approval.Id); + var second = await _repository.GetByIdAsync(approval.Id); + + Assert.IsTrue(await _service.Close(first, OrderApprovalStatus.Approved, "staff")); + Assert.IsFalse(await _service.Close(second, OrderApprovalStatus.Withdrawn)); + + Assert.AreEqual(OrderApprovalStatus.Approved, (await _repository.GetByIdAsync(approval.Id)).Status); + } + + [TestMethod] + public async Task Reopen_MakesItDecidableAgain() + { + var approval = await AddPending(); + await _service.Close(approval, OrderApprovalStatus.Rejected, "staff"); + + await _service.Reopen(approval); + + Assert.AreEqual(OrderApprovalStatus.Pending, (await _repository.GetByIdAsync(approval.Id)).Status); + Assert.IsTrue(await _service.Close(approval, OrderApprovalStatus.Approved, "staff")); + } +} diff --git a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderApprovalServiceTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderApprovalServiceTests.cs index d882124f0..c9be7170f 100644 --- a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderApprovalServiceTests.cs +++ b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderApprovalServiceTests.cs @@ -1,13 +1,9 @@ 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; @@ -22,55 +18,20 @@ public class OrderApprovalServiceTests 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 - } - ]; + _service = new OrderApprovalService(_repository, _approvalSettings, _mediatorMock.Object); } private async Task AddApproval(OrderApprovalStatus status, string orderId = "order1", @@ -93,91 +54,6 @@ private async Task AddApproval(OrderApprovalStatus status, string 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] @@ -324,6 +200,20 @@ public async Task GetForCustomer_NewestFirstAndOnlyOwn() CollectionAssert.AreEqual(new[] { newer.Id, older.Id }, result.Select(x => x.Id).ToArray()); } + [TestMethod] + public async Task GetForCustomer_LeavesOutStoreDecidedRequests() + { + var companyDecided = await AddApproval(OrderApprovalStatus.Pending); + await _repository.InsertAsync(new OrderApproval { + CustomerId = BuyerId, StoreId = StoreId, Decider = OrderApprovalDecider.Store, + Status = OrderApprovalStatus.Pending + }); + + var result = await _service.GetForCustomer(BuyerId, StoreId, 0, 10); + + CollectionAssert.AreEqual(new[] { companyDecided.Id }, result.Select(x => x.Id).ToArray()); + } + [TestMethod] public async Task Insert_StoresAndPublishesEvent() { @@ -336,18 +226,57 @@ public async Task Insert_StoresAndPublishesEvent() } [TestMethod] - public async Task Update_StoresAndPublishesEvent() + public async Task Close_Pending_StoresTheDecisionAndPublishesEvent() + { + var approval = await AddApproval(OrderApprovalStatus.Pending); + + var closed = await _service.Close(approval, OrderApprovalStatus.Approved, "staff", "fine"); + + Assert.IsTrue(closed); + Assert.AreEqual(OrderApprovalStatus.Approved, approval.Status); + var stored = await _repository.GetByIdAsync(approval.Id); + Assert.AreEqual(OrderApprovalStatus.Approved, stored.Status); + Assert.AreEqual("staff", stored.DecidedByCustomerId); + Assert.AreEqual("fine", stored.DecisionComment); + Assert.IsNotNull(stored.DecidedOnUtc); + _mediatorMock.Verify(x => x.Publish(It.IsAny>(), default), Times.Once); + } + + [TestMethod] + public async Task Close_ACopyLoadedBeforeAnotherClose_LosesAndChangesNothing() { + //both requests loaded the request while it was pending: this is what two clicks at once look like var approval = await AddApproval(OrderApprovalStatus.Pending); - approval.Status = OrderApprovalStatus.Approved; + var first = await _repository.GetByIdAsync(approval.Id); + var second = await _repository.GetByIdAsync(approval.Id); - await _service.Update(approval); + Assert.IsTrue(await _service.Close(first, OrderApprovalStatus.Approved, "staff")); + Assert.IsFalse(await _service.Close(second, OrderApprovalStatus.Withdrawn)); + Assert.AreEqual(OrderApprovalStatus.Pending, second.Status); var stored = await _repository.GetByIdAsync(approval.Id); Assert.AreEqual(OrderApprovalStatus.Approved, stored.Status); + Assert.AreEqual("staff", stored.DecidedByCustomerId); _mediatorMock.Verify(x => x.Publish(It.IsAny>(), default), Times.Once); } + [TestMethod] + public async Task Reopen_MakesTheRequestPendingAgainAndDecidable() + { + var approval = await AddApproval(OrderApprovalStatus.Pending); + await _service.Close(approval, OrderApprovalStatus.Rejected, "staff", "no"); + + await _service.Reopen(approval); + + Assert.AreEqual(OrderApprovalStatus.Pending, approval.Status); + Assert.IsNull(approval.DecidedOnUtc); + var stored = await _repository.GetByIdAsync(approval.Id); + Assert.AreEqual(OrderApprovalStatus.Pending, stored.Status); + Assert.IsNull(stored.DecidedByCustomerId); + Assert.IsNull(stored.DecisionComment); + Assert.IsTrue(await _service.Close(approval, OrderApprovalStatus.Approved, "staff")); + } + [TestMethod] public async Task Insert_Null_Throws() { diff --git a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderPlacementServiceTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderPlacementServiceTests.cs new file mode 100644 index 000000000..87a0be53d --- /dev/null +++ b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderPlacementServiceTests.cs @@ -0,0 +1,344 @@ +using Grand.Business.Checkout.Services.Orders; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Utilities.Checkout; +using Grand.Domain.Customers; +using Grand.Domain.Directory; +using Grand.Domain.Localization; +using Grand.Domain.Orders; +using Grand.Domain.Stores; +using Grand.Domain.Tax; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Business.Checkout.Tests.Services.Orders; + +[TestClass] +public class OrderPlacementServiceTests +{ + private const string RuleFailedText = "cannot be placed right now"; + private const string ApprovalRequiredText = "needs approval"; + private const string BlockedText = "cannot be placed"; + + private readonly List _cart = [new() { ProductId = "p1" }]; + + private readonly Language _language = new() { Id = "pl" }; + + private CalculationContext _calculation; + private Mock _calculationService; + private Mock> _logger; + private List _rules; + private OrderPlacementService _service; + + [TestInitialize] + public void Init() + { + _rules = []; + var customer = new Customer { Id = "c1" }; + customer.Groups.Add("g1"); + _calculation = new CalculationContext(customer, new Store { Id = "s1" }, new Currency(), + TaxDisplayType.IncludingTax); + _calculationService = new Mock(); + _calculationService + .Setup(x => x.GetShoppingCartTotal(_cart, _calculation, It.IsAny(), It.IsAny())) + .ReturnsAsync(new ShoppingCartTotal(150, 0, [], [], 0, 0)); + _logger = new Mock>(); + + var translation = new Mock(); + Text(translation, "Checkout.OrderPlacement.RuleFailed", RuleFailedText); + Text(translation, "Checkout.OrderPlacement.ApprovalRequired", ApprovalRequiredText); + Text(translation, "Checkout.OrderPlacement.Blocked", BlockedText); + + _service = new OrderPlacementService(_rules, _calculationService.Object, translation.Object, _logger.Object); + } + + private static void Text(Mock translation, string key, string text) + { + translation.Setup(x => x.GetResource(key, "pl", It.IsAny(), It.IsAny())).Returns(text); + } + + private Mock Rule(OrderPlacementRuleResult result, int priority = 0, + IList stores = null, IList groups = null) + { + var rule = new Mock(); + rule.SetupGet(x => x.SystemName).Returns("Rule"); + rule.SetupGet(x => x.Priority).Returns(priority); + rule.SetupGet(x => x.LimitedToStores).Returns(stores ?? []); + rule.SetupGet(x => x.LimitedToGroups).Returns(groups ?? []); + rule.Setup(x => x.Evaluate(It.IsAny(), It.IsAny())).ReturnsAsync(result); + _rules.Add(rule.Object); + return rule; + } + + private Task Evaluate() + { + return _service.Evaluate(_cart, _calculation, _language, "Payments.CashOnDelivery"); + } + + [TestMethod] + public async Task Evaluate_NoProviders_Allows() + { + var result = await Evaluate(); + + Assert.AreEqual(OrderPlacementDecision.Allow, result.Decision); + Assert.AreEqual(0, result.Reasons.Count); + } + + [TestMethod] + public async Task Evaluate_AnyRequiresApproval_RequiresApproval() + { + Rule(OrderPlacementRuleResult.Allow()); + Rule(OrderPlacementRuleResult.RequireApproval("a")); + Rule(OrderPlacementRuleResult.Allow()); + + var result = await Evaluate(); + + Assert.AreEqual(OrderPlacementDecision.RequireApproval, result.Decision); + CollectionAssert.AreEqual(new[] { "a" }, result.Reasons.ToArray()); + } + + [TestMethod] + public async Task Evaluate_BlockBeatsApproval_ReasonsOnlyFromBlock() + { + Rule(OrderPlacementRuleResult.RequireApproval("a")); + Rule(OrderPlacementRuleResult.Block("b")); + + var result = await Evaluate(); + + Assert.AreEqual(OrderPlacementDecision.Block, result.Decision); + CollectionAssert.AreEqual(new[] { "b" }, result.Reasons.ToArray()); + } + + [TestMethod] + public async Task Evaluate_RunsEveryProvider() + { + var first = Rule(OrderPlacementRuleResult.Block("a")); + var second = Rule(OrderPlacementRuleResult.Block("b")); + + var result = await Evaluate(); + + first.Verify(x => x.Evaluate(It.IsAny(), It.IsAny()), Times.Once); + second.Verify(x => x.Evaluate(It.IsAny(), It.IsAny()), Times.Once); + CollectionAssert.AreEqual(new[] { "a", "b" }, result.Reasons.ToArray()); + } + + [TestMethod] + public async Task Evaluate_ReasonsFollowPriority() + { + Rule(OrderPlacementRuleResult.RequireApproval("later"), 20); + Rule(OrderPlacementRuleResult.RequireApproval("sooner"), 10); + + var result = await Evaluate(); + + CollectionAssert.AreEqual(new[] { "sooner", "later" }, result.Reasons.ToArray()); + } + + [TestMethod] + public async Task Evaluate_PassesTheOrderBeingPlacedToTheRule() + { + OrderPlacementContext seen = null; + var rule = Rule(OrderPlacementRuleResult.Allow()); + rule.Setup(x => x.Evaluate(It.IsAny(), It.IsAny())) + .Callback((c, _) => seen = c) + .ReturnsAsync(OrderPlacementRuleResult.Allow()); + + await Evaluate(); + + Assert.AreSame(_cart, seen.Cart); + Assert.AreSame(_calculation, seen.Calculation); + Assert.AreSame(_language, seen.Language); + Assert.AreEqual("Payments.CashOnDelivery", seen.PaymentMethodSystemName); + } + + [TestMethod] + public async Task Evaluate_SkipsProviderLimitedToAnotherStore() + { + var other = Rule(OrderPlacementRuleResult.Block("other store"), stores: ["s2"]); + Rule(OrderPlacementRuleResult.RequireApproval("this store"), stores: ["s1"]); + + var result = await Evaluate(); + + Assert.AreEqual(OrderPlacementDecision.RequireApproval, result.Decision); + other.Verify(x => x.Evaluate(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Evaluate_SkipsProviderLimitedToAnotherGroup() + { + var other = Rule(OrderPlacementRuleResult.Block("other group"), groups: ["g2"]); + Rule(OrderPlacementRuleResult.RequireApproval("this group"), groups: ["g1"]); + + var result = await Evaluate(); + + Assert.AreEqual(OrderPlacementDecision.RequireApproval, result.Decision); + other.Verify(x => x.Evaluate(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Evaluate_ThrowingProvider_BlocksWithGenericReasonAndLogs() + { + Rule(OrderPlacementRuleResult.RequireApproval("a")); + var failing = Rule(OrderPlacementRuleResult.Allow()); + failing.Setup(x => x.Evaluate(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("connection string secret")); + + var result = await Evaluate(); + + Assert.AreEqual(OrderPlacementDecision.Block, result.Decision); + CollectionAssert.AreEqual(new[] { RuleFailedText }, result.Reasons.ToArray()); + _logger.Verify(x => x.Log(LogLevel.Error, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>()), Times.Once); + } + + [TestMethod] + public async Task Evaluate_ProviderReturnsNothing_Blocks() + { + Rule(null); + + var result = await Evaluate(); + + Assert.AreEqual(OrderPlacementDecision.Block, result.Decision); + CollectionAssert.AreEqual(new[] { RuleFailedText }, result.Reasons.ToArray()); + } + + [TestMethod] + public async Task Evaluate_EmptyReason_FallsBackToResource() + { + Rule(OrderPlacementRuleResult.RequireApproval(" ")); + + var approval = await Evaluate(); + + CollectionAssert.AreEqual(new[] { ApprovalRequiredText }, approval.Reasons.ToArray()); + + Rule(OrderPlacementRuleResult.Block(null)); + + var block = await Evaluate(); + + CollectionAssert.AreEqual(new[] { BlockedText }, block.Reasons.ToArray()); + } + + [TestMethod] + public async Task Evaluate_LongReason_IsTrimmedAndCappedAt500() + { + Rule(OrderPlacementRuleResult.RequireApproval(" " + new string('x', 600) + " ")); + + var result = await Evaluate(); + + Assert.AreEqual(new string('x', 500), result.Reasons[0]); + } + + [TestMethod] + public async Task Evaluate_TwoProvidersAskForTotal_CalculatesOnce() + { + var totals = new List(); + foreach (var _ in new[] { 1, 2 }) + { + var rule = Rule(OrderPlacementRuleResult.Allow()); + rule.Setup(x => x.Evaluate(It.IsAny(), It.IsAny())) + .Returns(async (OrderPlacementContext c, CancellationToken _) => + { + totals.Add(await c.GetTotal()); + return OrderPlacementRuleResult.Allow(); + }); + } + + await Evaluate(); + + CollectionAssert.AreEqual(new double?[] { 150, 150 }, totals); + _calculationService.Verify(x => x.GetShoppingCartTotal(_cart, _calculation, It.IsAny(), + It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task Evaluate_SameReasonFromTwoRules_IsListedOnce() + { + var first = Rule(OrderPlacementRuleResult.Allow()); + first.Setup(x => x.Evaluate(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("first")); + var second = Rule(OrderPlacementRuleResult.Allow()); + second.Setup(x => x.Evaluate(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("second")); + + var result = await Evaluate(); + + CollectionAssert.AreEqual(new[] { RuleFailedText }, result.Reasons.ToArray()); + } + + [TestMethod] + public async Task Evaluate_PassesTheTokenToTheRule() + { + using var source = new CancellationTokenSource(); + var rule = Rule(OrderPlacementRuleResult.Allow()); + + await _service.Evaluate(_cart, _calculation, _language, "Payments.CashOnDelivery", source.Token); + + rule.Verify(x => x.Evaluate(It.IsAny(), source.Token), Times.Once); + } + + [TestMethod] + public async Task Evaluate_RequestAborted_ThrowsInsteadOfBlockingOrLogging() + { + using var source = new CancellationTokenSource(); + var rule = Rule(OrderPlacementRuleResult.Allow()); + rule.Setup(x => x.Evaluate(It.IsAny(), source.Token)) + .Returns(() => + { + source.Cancel(); + return Task.FromCanceled(source.Token); + }); + + await Assert.ThrowsAsync(() => + _service.Evaluate(_cart, _calculation, _language, "Payments.CashOnDelivery", source.Token)); + + _logger.Verify(x => x.Log(LogLevel.Error, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>()), Times.Never); + } + + [TestMethod] + public async Task Evaluate_RuleTimesOutWhileTheRequestLives_Blocks() + { + //a rule's own timeout looks like a cancellation, but nobody aborted the request: it must not open the gate + var rule = Rule(OrderPlacementRuleResult.Allow()); + rule.Setup(x => x.Evaluate(It.IsAny(), It.IsAny())) + .ThrowsAsync(new TaskCanceledException("credit service timeout")); + + var result = await Evaluate(); + + Assert.AreEqual(OrderPlacementDecision.Block, result.Decision); + CollectionAssert.AreEqual(new[] { RuleFailedText }, result.Reasons.ToArray()); + } + + [TestMethod] + public async Task Evaluate_OneRuleWantsTheStoreToDecide_StoreDecides() + { + Rule(OrderPlacementRuleResult.RequireApproval("over the limit")); + Rule(OrderPlacementRuleResult.RequireStoreApproval("restricted goods")); + + var result = await Evaluate(); + + Assert.AreEqual(OrderPlacementDecision.RequireApproval, result.Decision); + Assert.IsTrue(result.StoreDecides); + } + + [TestMethod] + public async Task Evaluate_NoRuleWantsTheStoreToDecide_CompanyMayDecide() + { + Rule(OrderPlacementRuleResult.RequireApproval("over the limit")); + + var result = await Evaluate(); + + Assert.IsFalse(result.StoreDecides); + } + + [TestMethod] + public async Task Evaluate_NoProviderAsksForTotal_DoesNotCalculate() + { + Rule(OrderPlacementRuleResult.Allow()); + + await Evaluate(); + + _calculationService.Verify(x => x.GetShoppingCartTotal(It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/src/Tests/Grand.Business.Customers.Tests/Queries/Handlers/GetCustomerQueryHandlerTests.cs b/src/Tests/Grand.Business.Customers.Tests/Queries/Handlers/GetCustomerQueryHandlerTests.cs index cfdfd07cf..a534e23e9 100644 --- a/src/Tests/Grand.Business.Customers.Tests/Queries/Handlers/GetCustomerQueryHandlerTests.cs +++ b/src/Tests/Grand.Business.Customers.Tests/Queries/Handlers/GetCustomerQueryHandlerTests.cs @@ -47,6 +47,21 @@ public async Task Handle_PageSizeTwo_ReturnsFirstPageAndTotal() Assert.AreEqual(2, result.TotalPages); } + [TestMethod] + public async Task Handle_StaffStoreId_ReturnsOnlyThatStoresStaff() + { + var repository = new MongoDBRepositoryTest(); + var manager = new Customer { Email = "manager@x.com", StaffStoreId = "s1" }; + repository.Insert(manager); + repository.Insert(new Customer { Email = "other@x.com", StaffStoreId = "s2" }); + repository.Insert(new Customer { Email = "customer@x.com" }); + + var result = await new GetCustomerQueryHandler(repository).Handle(new GetCustomerQuery { StaffStoreId = "s1" }, + CancellationToken.None); + + Assert.AreEqual(manager.Id, result.Single().Id); + } + [TestMethod] public async Task Handle_ExcludedGroupsAndStaffAccounts_LeftOutInTheQuery() { diff --git a/src/Tests/Grand.Business.Messages.Tests/Commands/GetOrderApprovalTokensCommandHandlerTests.cs b/src/Tests/Grand.Business.Messages.Tests/Commands/GetOrderApprovalTokensCommandHandlerTests.cs index a67f09c49..8470c827e 100644 --- a/src/Tests/Grand.Business.Messages.Tests/Commands/GetOrderApprovalTokensCommandHandlerTests.cs +++ b/src/Tests/Grand.Business.Messages.Tests/Commands/GetOrderApprovalTokensCommandHandlerTests.cs @@ -2,6 +2,7 @@ using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Messages.Commands.Handlers.Messages; using Grand.Business.Messages.Commands.Tokens; +using Grand.Domain.Common; using Grand.Domain.Customers; using Grand.Domain.Localization; using Grand.Domain.Orders; @@ -55,6 +56,91 @@ private static GetOrderApprovalTokensCommand Command(OrderApproval approval, Dom }; } + [TestMethod] + public async Task BuyerEmail_Guest_ComesFromTheBillingAddress() + { + _orderService.Setup(x => x.GetOrderById("order1")).ReturnsAsync(new Order { + Id = "order1", BillingAddress = new Address { Email = "guest@example.com" } + }); + var command = Command(Approval()); + command.Buyer = new Customer(); + + var tokens = await _handler.Handle(command, default); + + Assert.AreEqual("guest@example.com", tokens.BuyerEmail); + } + + [TestMethod] + public async Task BuyerName_Guest_ComesFromTheBillingAddress() + { + _orderService.Setup(x => x.GetOrderById("order1")).ReturnsAsync(new Order { + Id = "order1", BillingAddress = new Address { FirstName = "Anna", LastName = "Nowak" } + }); + var command = Command(Approval()); + command.Buyer = new Customer(); + + var tokens = await _handler.Handle(command, default); + + Assert.AreEqual("Anna Nowak", tokens.BuyerName); + } + + [TestMethod] + public async Task ReasonsHtml_EncodesEachReason() + { + var approval = Approval(); + approval.Reasons = ["", "A & B"]; + + var tokens = await _handler.Handle(Command(approval), default); + + Assert.AreEqual("
  • <script>{{x}}</script>
  • A & B
", tokens.ReasonsHtml); + } + + [TestMethod] + public async Task ReasonsHtml_NoReasons_IsEmpty() + { + var tokens = await _handler.Handle(Command(Approval()), default); + + Assert.AreEqual(string.Empty, tokens.ReasonsHtml); + } + + [TestMethod] + public async Task PanelUrl_UsesTheGivenArea() + { + var command = Command(Approval()); + command.PanelArea = "Store"; + + var tokens = await _handler.Handle(command, default); + + Assert.AreEqual("https://store.example/Store/Order/Edit/order1", tokens.PanelUrl); + } + + [TestMethod] + public async Task PanelUrl_EmptyWithoutArea() + { + var tokens = await _handler.Handle(Command(Approval()), default); + + Assert.AreEqual(string.Empty, tokens.PanelUrl); + } + + [TestMethod] + public async Task Url_StoreDecided_PointsToOrderDetails() + { + var approval = Approval(); + approval.Decider = OrderApprovalDecider.Store; + + var tokens = await _handler.Handle(Command(approval), default); + + Assert.AreEqual("https://store.example/orderdetails/order1", tokens.Url); + } + + [TestMethod] + public async Task Url_CompanyDecided_PointsToApprovalRequest() + { + var tokens = await _handler.Handle(Command(Approval()), default); + + Assert.AreEqual("https://store.example/account/orderapproval?id=approval1", tokens.Url); + } + [TestMethod] public async Task Handle_WithoutAnOrderId_FallsBackToTheApprovalUrlAndLeavesTheNumberEmpty() { diff --git a/src/Tests/Grand.Business.Messages.Tests/Services/CustomerMessageProviderServiceOrderApprovalTests.cs b/src/Tests/Grand.Business.Messages.Tests/Services/CustomerMessageProviderServiceOrderApprovalTests.cs index 0b5dfb608..eb5ad1b6e 100644 --- a/src/Tests/Grand.Business.Messages.Tests/Services/CustomerMessageProviderServiceOrderApprovalTests.cs +++ b/src/Tests/Grand.Business.Messages.Tests/Services/CustomerMessageProviderServiceOrderApprovalTests.cs @@ -81,6 +81,53 @@ public async Task SendRequested_ToApproverEmail_WithTemplate() _queuedEmailServiceMock.Verify(x => x.InsertQueuedEmail(It.IsAny()), Times.Once); } + [TestMethod] + public async Task SendDecided_GivenAddress_GoesThereInsteadOfTheBuyersOwn() + { + TemplateExists(MessageTemplateNames.OrderApprovalDecided, "Decided", "Body"); + var guest = new Customer { Id = "guest" }; + + await _service.SendOrderApprovalDecidedMessage(_approval, guest, _store, "en", null, "guest@example.com"); + + Assert.AreEqual("guest@example.com", _queued.To); + } + + [TestMethod] + public async Task SendOrderApprovalStaffMessage_GivenRecipient_AddressesIt() + { + TemplateExists(MessageTemplateNames.OrderApprovalStaffRequested, "Decide", "Body"); + + var result = await _service.SendOrderApprovalStaffMessage(_approval, _buyer, _store, "sales@example.com", + "Sales", "Admin", "en"); + + Assert.AreEqual(1, result); + Assert.AreEqual("sales@example.com", _queued.To); + Assert.AreEqual("Sales", _queued.ToName); + } + + [TestMethod] + public async Task SendOrderApprovalStaffMessage_EmptyRecipient_UsesEmailAccount() + { + TemplateExists(MessageTemplateNames.OrderApprovalStaffRequested, "Decide", "Body"); + + await _service.SendOrderApprovalStaffMessage(_approval, _buyer, _store, null, null, "Admin", "en"); + + Assert.AreEqual("shop@example.com", _queued.To); + Assert.AreEqual("Shop", _queued.ToName); + } + + [TestMethod] + public async Task SendOrderApprovalStaffMessage_PassesThePanelAreaToTheTokens() + { + TemplateExists(MessageTemplateNames.OrderApprovalStaffRequested, "Decide", "Body"); + + await _service.SendOrderApprovalStaffMessage(_approval, _buyer, _store, "sales@example.com", "Sales", "Store", + "en"); + + _mediatorMock.Verify(x => x.Send(It.Is(c => c.PanelArea == "Store"), default), + Times.Once); + } + [TestMethod] public async Task SendRequested_TemplateMissing_ReturnsZero() { diff --git a/src/Tests/Grand.Modules.Tests/Installer/MessageTemplateSeedTests.cs b/src/Tests/Grand.Modules.Tests/Installer/MessageTemplateSeedTests.cs index 88eab329f..e6817485b 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_Returns53UniqueTemplates() + public void Build_Returns54UniqueTemplates() { var templates = MessageTemplateSeed.Build("ea"); - Assert.AreEqual(53, templates.Count); - Assert.AreEqual(53, templates.Select(t => t.Name).Distinct().Count()); + Assert.AreEqual(54, templates.Count); + Assert.AreEqual(54, templates.Select(t => t.Name).Distinct().Count()); Assert.IsTrue(templates.All(t => t.EmailAccountId == "ea")); } diff --git a/src/Tests/Grand.Modules.Tests/Installer/OrderApprovalResourceKeysTests.cs b/src/Tests/Grand.Modules.Tests/Installer/OrderApprovalResourceKeysTests.cs index ac801308a..325b93a81 100644 --- a/src/Tests/Grand.Modules.Tests/Installer/OrderApprovalResourceKeysTests.cs +++ b/src/Tests/Grand.Modules.Tests/Installer/OrderApprovalResourceKeysTests.cs @@ -16,10 +16,13 @@ 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$" + + @"^(checkout\.orderapproval\.|checkout\.orderplacement\.|orderapproval\.|account\.orderapprovals$" + @"|account\.subaccounts\.fields\.(orderapprovallimit|role)(\.|$)" + @"|admin\.customers\.customers\.subaccounts\.(orderapprovallimit|role)$" + @"|admin\.configuration\.payment\.settings\.orderapprovalpaymentmethods$" + + @"|admin\.configuration\.stores\.fields\.ownercustomerid(\.invalid)?$" + + @"|admin\.orders\.approval\." + + @"|order\.approval\." + @"|admin\.orders\.fields\.orderstatus\.change\.(to)?awaitingapproval$" + @"|admin\.orders\.shipments\.orderawaitsapproval$" + @"|enums\.grand\.domain\.orders\.orderapprovalstatus\." + @@ -31,7 +34,7 @@ public class OrderApprovalResourceKeysTests //message template names look like resource keys but are not private static readonly HashSet TemplateNames = - new(StringComparer.OrdinalIgnoreCase) { "OrderApproval.Requested", "OrderApproval.Decided" }; + new(StringComparer.OrdinalIgnoreCase) { "OrderApproval.Requested", "OrderApproval.Decided", "OrderApproval.StaffRequested" }; private static readonly string[] SkippedFolders = ["Tests", "bin", "obj", "node_modules", "App_Data", "wwwroot"]; @@ -81,7 +84,8 @@ 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("Checkout.OrderApproval.WillBeSentGeneric")); + Assert.IsTrue(referenced.Contains("Checkout.OrderPlacement.RuleFailed")); 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")); diff --git a/src/Tests/Grand.Modules.Tests/Installer/OrderApprovalTemplateSeedTests.cs b/src/Tests/Grand.Modules.Tests/Installer/OrderApprovalTemplateSeedTests.cs index 1df7466c4..bdb48a3a4 100644 --- a/src/Tests/Grand.Modules.Tests/Installer/OrderApprovalTemplateSeedTests.cs +++ b/src/Tests/Grand.Modules.Tests/Installer/OrderApprovalTemplateSeedTests.cs @@ -16,6 +16,7 @@ private static MessageTemplate Template(string name) [TestMethod] [DataRow("OrderApproval.Requested")] [DataRow("OrderApproval.Decided")] + [DataRow("OrderApproval.StaffRequested")] public void OrderApprovalTemplates_AreActiveAndUseOnlyBackedTokens(string name) { var template = Template(name); @@ -52,6 +53,26 @@ public void Requested_RendersEncodedBuyerAndItemsHtml() StringAssert.Contains(body, "href='https://store.example/account/orderapproval?id=1'"); } + [TestMethod] + public void StaffRequested_LinksToThePanelAndShowsEncodedReasons() + { + var liquidObject = new LiquidObject { + OrderApproval = new LiquidOrderApproval { + BuyerName = "A & B", + OrderNumber = "1042", + ReasonsHtml = "
  • REASON
", + PanelUrl = "https://store.example/Admin/Order/Edit/1" + } + }; + + var body = LiquidExtensions.Render(liquidObject, Template("OrderApproval.StaffRequested").Body); + + StringAssert.Contains(body, "A <b>& B"); + StringAssert.Contains(body, "
  • REASON
"); + StringAssert.Contains(body, "href='https://store.example/Admin/Order/Edit/1'"); + StringAssert.Contains(body, "1042"); + } + [TestMethod] public void Requested_ShowsTheOrderNumber() { diff --git a/src/Tests/Grand.Modules.Tests/Installer/OrderApprovalViewsTests.cs b/src/Tests/Grand.Modules.Tests/Installer/OrderApprovalViewsTests.cs new file mode 100644 index 000000000..b3f361b8e --- /dev/null +++ b/src/Tests/Grand.Modules.Tests/Installer/OrderApprovalViewsTests.cs @@ -0,0 +1,45 @@ +using System.Text.RegularExpressions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Modules.Tests.Installer; + +/// +/// A reason comes from a plugin and a note from a person, and storefront content inside a Vue root is compiled as +/// a template: without v-pre "{{ }}" in such a text would run as an expression. Rendering a Razor view needs the +/// whole application, so the markup itself is checked. +/// +[TestClass] +public class OrderApprovalViewsTests +{ + [TestMethod] + [DataRow("Order/Details.cshtml", "reason")] + [DataRow("Order/Details.cshtml", "Model.OrderApprovalComment")] + [DataRow("OrderApproval/Details.cshtml", "reason")] + public void Views_RenderReasonsWithVPre(string view, string value) + { + var path = Path.Combine(RepositoryPaths.Root, "src", "Web", "Grand.Web", "Views", view); + var markup = File.ReadAllText(path); + + var bound = Regex.Matches(markup, $@"<(\w+)([^>]*)>\s*@{Regex.Escape(value)}\s*"); + + Assert.IsTrue(bound.Count > 0, $"{view} does not show {value}"); + foreach (Match match in bound) + StringAssert.Contains(match.Groups[2].Value, "v-pre", $"{view}: {match.Value}"); + } + + /// + /// Two overlapping posts of one decision would both pass the status check and move the order twice + /// + [TestMethod] + [DataRow("Web/Grand.Web.AdminShared/Views/AdminShared/Order/DecideApprovalPopup.cshtml", "DecideApproval")] + [DataRow("Web/Grand.Web/Views/Order/Details.cshtml", "WithdrawOrderApproval")] + public void DecisionForms_SubmitOnce(string view, string target) + { + var markup = File.ReadAllText(Path.Combine(RepositoryPaths.Root, "src", view)); + + var form = Regex.Match(markup, $@"]*""{target}""[^>]*>"); + + Assert.IsTrue(form.Success, $"{view} has no form for {target}"); + StringAssert.Contains(form.Value, "onsubmit=\"@submitOnce\"", view); + } +} diff --git a/src/Tests/Grand.Modules.Tests/Plugins/PluginDependencyCycleTests.cs b/src/Tests/Grand.Modules.Tests/Plugins/PluginDependencyCycleTests.cs index 01b0ad518..0a5c7fe8b 100644 --- a/src/Tests/Grand.Modules.Tests/Plugins/PluginDependencyCycleTests.cs +++ b/src/Tests/Grand.Modules.Tests/Plugins/PluginDependencyCycleTests.cs @@ -19,6 +19,7 @@ using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Utilities.Checkout; using Grand.Data; using Grand.Domain.Catalog; using Grand.Domain.Customers; @@ -100,6 +101,60 @@ public void PaymentProviders_DoNotDependBackOnOrderCalculation() Assert.IsNotNull(scope.ServiceProvider.GetRequiredService()); } + private class RuleUsingOrderCalculation(IOrderCalculationService orderCalculationService) + : IOrderPlacementRuleProvider + { + public IOrderCalculationService OrderCalculationService { get; } = orderCalculationService; + public string ConfigurationUrl => null; + public string SystemName => "Test.Rule"; + public string FriendlyName => SystemName; + public int Priority => 0; + public IList LimitedToStores => []; + public IList LimitedToGroups => []; + + public Task Evaluate(OrderPlacementContext context, + CancellationToken cancellationToken = default) + { + return Task.FromResult(OrderPlacementRuleResult.Allow()); + } + } + + [TestMethod] + public void OrderPlacementRules_DoNotDependBackOnPlacement() + { + var services = new ServiceCollection(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + AddMocks(services, typeof(IContextAccessor), typeof(IPricingService), typeof(ITaxService), + typeof(IShippingService), typeof(ICheckoutAttributeParser), typeof(IDiscountService), + typeof(IGiftVoucherService), typeof(ILoyaltyPointsService), typeof(IProductService), + typeof(ICurrencyService), typeof(IGroupService), typeof(IDiscountValidationService), + typeof(ISettingService), typeof(ITranslationService), typeof(IRequestTranslationService), + typeof(IPaymentTransactionService), typeof(ICustomerService), typeof(IHttpContextAccessor), + typeof(IPriceFormatter), typeof(ILogger)); + services.AddSingleton(new TaxSettings()); + services.AddSingleton(new LoyaltyPointsSettings()); + services.AddSingleton(new ShippingSettings()); + services.AddSingleton(new ShoppingCartSettings()); + services.AddSingleton(new CatalogSettings()); + services.AddSingleton(new PaymentSettings()); + services.AddSingleton(new CashOnDeliveryPaymentSettings()); + services.AddSingleton(new CustomerSettings()); + + using var root = Build(services); + using var scope = root.CreateScope(); + + Assert.IsNotNull(scope.ServiceProvider.GetRequiredService()); + } + [TestMethod] public void StandardDiscountRules_DoNotDependBackOnPricing() { diff --git a/src/Tests/Grand.Modules.Tests/Services/Migrations/MigrationOrderApprovalStaffTemplateTests.cs b/src/Tests/Grand.Modules.Tests/Services/Migrations/MigrationOrderApprovalStaffTemplateTests.cs new file mode 100644 index 000000000..1e5c40b2c --- /dev/null +++ b/src/Tests/Grand.Modules.Tests/Services/Migrations/MigrationOrderApprovalStaffTemplateTests.cs @@ -0,0 +1,101 @@ +using Grand.Data; +using Grand.Data.Tests.LiteDb; +using Grand.Domain.Configuration; +using Grand.Domain.Messages; +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 MigrationOrderApprovalStaffTemplateTests +{ + private const string TemplateName = "OrderApproval.StaffRequested"; + + private Mock _cache; + private LiteDBRepositoryMock _emailAccounts; + private MigrationOrderApprovalStaffTemplate _migration; + private IServiceProvider _serviceProvider; + private LiteDBRepositoryMock _templates; + + [TestInitialize] + public void Init() + { + _templates = new LiteDBRepositoryMock(); + _emailAccounts = new LiteDBRepositoryMock(); + _cache = new Mock(); + + var services = new ServiceCollection(); + services.AddSingleton>(_templates); + services.AddSingleton>(_emailAccounts); + services.AddSingleton>(new LiteDBRepositoryMock()); + services.AddSingleton(_cache.Object); + services.AddSingleton>( + NullLogger.Instance); + _serviceProvider = services.BuildServiceProvider(); + _migration = new MigrationOrderApprovalStaffTemplate(); + } + + [TestMethod] + public async Task MigrationOrderApprovalStaffTemplate_InsertsMissing_LeavesExisting() + { + var account = new EmailAccount { Email = "first@example.com" }; + _emailAccounts.Insert(account); + + Assert.IsTrue(await _migration.UpgradeProcess(_serviceProvider)); + + var inserted = _templates.Table.Single(x => x.Name == TemplateName); + Assert.IsTrue(inserted.IsActive); + Assert.AreEqual(account.Id, inserted.EmailAccountId); + _cache.Verify(x => x.RemoveByPrefix(CacheKey.MESSAGETEMPLATES_PATTERN_KEY, It.IsAny()), Times.Once); + + inserted.Subject = "Our subject"; + _templates.Update(inserted); + + Assert.IsTrue(await _migration.UpgradeProcess(_serviceProvider)); + + Assert.AreEqual("Our subject", _templates.Table.Single(x => x.Name == TemplateName).Subject); + _cache.Verify(x => x.RemoveByPrefix(CacheKey.MESSAGETEMPLATES_PATTERN_KEY, It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task MigrationOrderApprovalStaffTemplate_RepositoryFails_ReturnsFalseWithoutThrowing() + { + var failing = new Mock>(); + failing.SetupGet(x => x.Table).Throws(new InvalidOperationException("db")); + var services = new ServiceCollection(); + services.AddSingleton(failing.Object); + services.AddSingleton>(_emailAccounts); + services.AddSingleton>(new LiteDBRepositoryMock()); + services.AddSingleton>( + NullLogger.Instance); + + Assert.IsFalse(await _migration.UpgradeProcess(services.BuildServiceProvider())); + } + + [TestMethod] + public void Template_UsesTheSubjectAndTokensOfTheInstallerSeed() + { + var seeded = MessageTemplateSeed.Build("ea").Single(x => x.Name == TemplateName); + var migrated = MigrationOrderApprovalStaffTemplate.Template("ea"); + + Assert.AreEqual(seeded.Subject, migrated.Subject); + CollectionAssert.AreEqual(MessageTemplateSeedTests.Tokens(seeded.Body).ToList(), + MessageTemplateSeedTests.Tokens(migrated.Body).ToList()); + Assert.AreEqual(Normalize(seeded.Body), Normalize(migrated.Body)); + } + + private static string Normalize(string body) + { + return Regex.Replace(body, @"\s+", " "); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseOrderManagementControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseOrderManagementControllerTests.cs index 723ed60c8..ec1c7d3f2 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseOrderManagementControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseOrderManagementControllerTests.cs @@ -55,6 +55,127 @@ private class TestOrderManagementController( private Mock> _scopeMock; private Mock _orderStatusServiceMock; private Mock _translationServiceMock; + private Mock _orderViewModelServiceMock; + private Grand.Domain.Customers.Customer _staff; + + private Order OrderInScope(bool inScope = true) + { + var order = new Order { Id = "o1" }; + _orderServiceMock.Setup(o => o.GetOrderById("o1")).ReturnsAsync(order); + _scopeMock.Setup(s => s.HasAccess(order)).ReturnsAsync(inScope); + return order; + } + + private void CommandAnswers(bool answer) + { + _mediatorMock.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(answer); + } + + private void NoCommandWasSent() + { + _mediatorMock.Verify(m => m.Send(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task DecideApproval_OutOfScope_RedirectsToListWithoutCommand() + { + OrderInScope(false); + + var result = await _controller.DecideApproval("o1", true, null); + + Assert.AreEqual("List", ((RedirectToActionResult)result).ActionName); + NoCommandWasSent(); + } + + [TestMethod] + public async Task DecideApproval_MissingApprove_RefusedWithoutCommand() + { + OrderInScope(); + + var result = await _controller.DecideApproval("o1", null, null); + + Assert.AreEqual("Edit", ((RedirectToActionResult)result).ActionName); + NoCommandWasSent(); + _translationServiceMock.Verify(t => t.GetResource("Admin.Orders.Approval.Refused"), Times.Once); + } + + [TestMethod] + public async Task DecideApproval_Approve_SendsCommandAndRedirectsToEdit() + { + var order = OrderInScope(); + CommandAnswers(true); + + var result = await _controller.DecideApproval("o1", true, "a note"); + + Assert.AreEqual("Edit", ((RedirectToActionResult)result).ActionName); + _mediatorMock.Verify(m => m.Send(It.Is(c => + c.Order == order && c.DecidedBy == _staff && c.Approve && c.Comment == "a note"), + It.IsAny()), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Orders.Approval.Approved"), Times.Once); + } + + [TestMethod] + public async Task DecideApproval_Reject_ReportsTheRejection() + { + OrderInScope(); + CommandAnswers(true); + + await _controller.DecideApproval("o1", false, null); + + _mediatorMock.Verify(m => m.Send(It.Is(c => !c.Approve), + It.IsAny()), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Orders.Approval.Rejected"), Times.Once); + } + + [TestMethod] + public async Task DecideApproval_CommandRefuses_ShowsRefusedError() + { + OrderInScope(); + CommandAnswers(false); + + await _controller.DecideApproval("o1", true, null); + + _translationServiceMock.Verify(t => t.GetResource("Admin.Orders.Approval.Refused"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Orders.Approval.Approved"), Times.Never); + } + + [TestMethod] + public async Task DecideApprovalPopup_CannotDecide_AnswersWithThePopupNotARedirect() + { + //the popup loads this over ajax: a redirect would put the whole Edit page inside it + var order = OrderInScope(); + _orderViewModelServiceMock.Setup(s => s.PrepareOrderApprovalDecisionModel(order)) + .ReturnsAsync((OrderApprovalDecisionModel)null); + + var result = await _controller.DecideApprovalPopup("o1"); + + Assert.AreEqual("DecideApprovalRefused", ((ViewResult)result).ViewName); + } + + [TestMethod] + public async Task DecideApprovalPopup_OutOfScope_AnswersWithThePopupNotARedirect() + { + OrderInScope(false); + + var result = await _controller.DecideApprovalPopup("o1"); + + Assert.AreEqual("DecideApprovalRefused", ((ViewResult)result).ViewName); + _orderViewModelServiceMock.Verify(s => s.PrepareOrderApprovalDecisionModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task DecideApprovalPopup_CanDecide_ShowsTheModel() + { + var order = OrderInScope(); + var model = new OrderApprovalDecisionModel { OrderId = "o1" }; + _orderViewModelServiceMock.Setup(s => s.PrepareOrderApprovalDecisionModel(order)).ReturnsAsync(model); + + var result = await _controller.DecideApprovalPopup("o1"); + + Assert.AreSame(model, ((ViewResult)result).Model); + } [TestInitialize] public void Setup() @@ -72,10 +193,17 @@ public void Setup() _orderStatusServiceMock.Setup(s => s.GetByStatusId(It.IsAny())) .ReturnsAsync((int statusId) => new OrderStatus { StatusId = statusId, Name = "status" }); + _orderViewModelServiceMock = new Mock(); + _staff = new Grand.Domain.Customers.Customer { Id = "staff" }; + var workContext = new Mock(); + workContext.Setup(w => w.CurrentCustomer).Returns(_staff); + var contextAccessor = new Mock(); + contextAccessor.Setup(c => c.WorkContext).Returns(workContext.Object); + _controller = new TestOrderManagementController( - new Mock().Object, _orderServiceMock.Object, + _orderViewModelServiceMock.Object, _orderServiceMock.Object, _orderStatusServiceMock.Object, _translationServiceMock.Object, - new Mock().Object, new Mock().Object, + contextAccessor.Object, new Mock().Object, _mediatorMock.Object, _scopeMock.Object, _orderNoteViewModelServiceMock.Object, _orderAddressViewModelServiceMock.Object, _orderProductViewModelServiceMock.Object); diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/OrderViewModelServiceApprovalTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/OrderViewModelServiceApprovalTests.cs new file mode 100644 index 000000000..f43916c4d --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Services/OrderViewModelServiceApprovalTests.cs @@ -0,0 +1,172 @@ +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.Checkout.GiftVouchers; +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.Addresses; +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.Storage; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Domain.Directory; +using Grand.Domain.Orders; +using Grand.Domain.Tax; +using Grand.Infrastructure; +using Grand.Mediator; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.AdminShared.Services; +using Grand.Web.Common.Localization; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Services; + +[TestClass] +public class OrderViewModelServiceApprovalTests +{ + private OrderApproval _approval; + private Order _order; + private Mock> _scope; + private OrderViewModelService _service; + + [TestInitialize] + public void Init() + { + _order = new Order { + Id = "o1", OrderNumber = 1042, CustomerId = "buyer", + OrderStatusId = (int)OrderStatusSystem.AwaitingApproval + }; + _approval = new OrderApproval { + OrderId = "o1", CustomerId = "buyer", Status = OrderApprovalStatus.Pending, + Decider = OrderApprovalDecider.Store, Reasons = ["over the limit"] + }; + var approvals = new Mock(); + approvals.Setup(x => x.GetByOrderId("o1")).ReturnsAsync(() => _approval); + + var work = new Mock(); + work.Setup(x => x.CurrentCustomer).Returns(new Customer { Id = "staff" }); + var context = new Mock(); + context.Setup(x => x.WorkContext).Returns(work.Object); + _scope = new Mock>(); + var enums = new Mock(); + enums.Setup(x => x.GetTranslationEnum(OrderApprovalStatus.Pending)).Returns("Pending"); + enums.Setup(x => x.GetTranslationEnum(OrderApprovalDecider.Store)).Returns("The store"); + enums.Setup(x => x.GetTranslationEnum(OrderApprovalDecider.Company)).Returns("The company"); + + _service = new OrderViewModelService(new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, + new Mock().Object, context.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 CurrencySettings(), new TaxSettings(), new AddressSettings(), + new Mock().Object, new Mock().Object, new Mock().Object, + enums.Object, _scope.Object, approvals.Object); + } + + private async Task Details() + { + var model = new OrderModel(); + await _service.PrepareOrderApprovalDetails(model, _order); + return model; + } + + [TestMethod] + public async Task PrepareOrderDetails_PendingStoreDecided_CanDecide() + { + var model = await Details(); + + Assert.IsTrue(model.CanDecideApproval); + Assert.AreEqual("Pending", model.ApprovalStatus); + Assert.AreEqual("The store", model.ApprovalDecider); + CollectionAssert.AreEqual(new[] { "over the limit" }, model.ApprovalReasons.ToArray()); + } + + [TestMethod] + public async Task PrepareOrderDetails_CompanyDecided_CannotDecideButShowsReasons() + { + _approval.Decider = OrderApprovalDecider.Company; + + var model = await Details(); + + Assert.IsFalse(model.CanDecideApproval); + Assert.AreEqual("The company", model.ApprovalDecider); + CollectionAssert.AreEqual(new[] { "over the limit" }, model.ApprovalReasons.ToArray()); + } + + [TestMethod] + public async Task PrepareOrderDetails_AlreadyDecided_CannotDecide() + { + _approval.Status = OrderApprovalStatus.Approved; + + Assert.IsFalse((await Details()).CanDecideApproval); + } + + [TestMethod] + public async Task PrepareOrderDetails_OrderNoLongerAwaiting_CannotDecide() + { + _order.OrderStatusId = (int)OrderStatusSystem.Cancelled; + + Assert.IsFalse((await Details()).CanDecideApproval); + } + + [TestMethod] + public async Task PrepareOrderDetails_VendorScope_CannotDecide() + { + _scope.Setup(x => x.DefaultVendorId).Returns("vendor"); + + var model = await Details(); + + Assert.IsFalse(model.CanDecideApproval); + Assert.IsNull(model.ApprovalStatus); + Assert.AreEqual(0, model.ApprovalReasons.Count); + } + + [TestMethod] + public async Task PrepareOrderDetails_OwnOrder_CannotDecide() + { + _approval.CustomerId = "staff"; + + Assert.IsFalse((await Details()).CanDecideApproval); + } + + [TestMethod] + public async Task PrepareOrderDetails_NoRequest_LeavesFieldsEmpty() + { + _approval = null; + + var model = await Details(); + + Assert.IsFalse(model.CanDecideApproval); + Assert.IsNull(model.ApprovalStatus); + Assert.IsNull(model.ApprovalDecider); + Assert.AreEqual(0, model.ApprovalReasons.Count); + } + + [TestMethod] + public async Task PrepareOrderApprovalDecisionModel_CanDecide_CarriesTheOrderAndReasons() + { + var model = await _service.PrepareOrderApprovalDecisionModel(_order); + + Assert.AreEqual("o1", model.OrderId); + Assert.AreEqual(1042, model.OrderNumber); + CollectionAssert.AreEqual(new[] { "over the limit" }, model.Reasons.ToArray()); + } + + [TestMethod] + public async Task PrepareOrderApprovalDecisionModel_CannotDecide_Null() + { + _approval.Decider = OrderApprovalDecider.Company; + + Assert.IsNull(await _service.PrepareOrderApprovalDecisionModel(_order)); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/StoreViewModelServiceOwnerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/StoreViewModelServiceOwnerTests.cs new file mode 100644 index 000000000..6b411eef1 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Services/StoreViewModelServiceOwnerTests.cs @@ -0,0 +1,122 @@ +using Grand.Business.Core.Interfaces.Checkout.Shipping; +using Grand.Business.Core.Interfaces.Common.Configuration; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Queries.Customers; +using Grand.Domain; +using Grand.Domain.Customers; +using Grand.Domain.Orders; +using Grand.Mediator; +using Grand.Web.AdminShared.Models.Stores; +using Grand.Web.AdminShared.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Services; + +[TestClass] +public class StoreViewModelServiceOwnerTests +{ + private Mock _mediator; + private StoreViewModelService _service; + + [TestInitialize] + public void Init() + { + var groupService = new Mock(); + groupService.Setup(x => x.GetCustomerGroupBySystemName(SystemCustomerGroupNames.Administrators)) + .ReturnsAsync(new CustomerGroup { Id = "admins" }); + groupService.Setup(x => x.GetCustomerGroupBySystemName(SystemCustomerGroupNames.StoreManager)) + .ReturnsAsync(new CustomerGroup { Id = "managers" }); + + var administrators = new List { + Staff("admin", "admins", email: "admin@x.example"), + Staff("inactive-admin", "admins", active: false), + Staff("deleted-admin", "admins", deleted: true) + }; + var customerService = new Mock(); + customerService.Setup(x => x.GetAllCustomers(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.Is(ids => ids.Length == 1 && ids[0] == "admins"), + 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(administrators, 0, int.MaxValue, administrators.Count)); + + //the store's managers come from a query scoped to the store, so another store's never reach memory + var managers = new List { Staff("manager", "managers", staffStoreId: "s1", email: "manager@x.example") }; + _mediator = new Mock(); + _mediator.Setup(x => x.Send( + It.Is(q => q.StaffStoreId == "s1" && q.CustomerGroupIds.Single() == "managers"), + It.IsAny())) + .ReturnsAsync(new PagedList(managers, 0, int.MaxValue, managers.Count)); + + _service = new StoreViewModelService(new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, new Mock().Object, + new Mock().Object, customerService.Object, groupService.Object, _mediator.Object); + } + + private static Customer Staff(string id, string groupId, string staffStoreId = null, bool active = true, + bool deleted = false, string email = null) + { + var customer = new Customer { + Id = id, StaffStoreId = staffStoreId, Active = active, Deleted = deleted, Email = email ?? $"{id}@x.example" + }; + customer.Groups.Add(groupId); + return customer; + } + + [TestMethod] + public async Task PrepareOwnerAccounts_ListsAdministratorsAndThisStoresManagers() + { + var model = new StoreModel { Id = "s1" }; + + await _service.PrepareOwnerAccountsModel(model); + + CollectionAssert.AreEqual(new[] { "", "admin", "manager" }, + model.AvailableOwnerAccounts.Select(x => x.Value).ToArray()); + Assert.AreEqual("---", model.AvailableOwnerAccounts[0].Text); + StringAssert.Contains(model.AvailableOwnerAccounts[1].Text, "admin@x.example"); + } + + [TestMethod] + public async Task PrepareOwnerAccounts_NewStore_ListsAdministratorsOnly() + { + var model = new StoreModel(); + + await _service.PrepareOwnerAccountsModel(model); + + CollectionAssert.AreEqual(new[] { "", "admin" }, model.AvailableOwnerAccounts.Select(x => x.Value).ToArray()); + _mediator.Verify(x => x.Send(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task IsOwnerCandidate_TrueForListedAccount() + { + Assert.IsTrue(await _service.IsOwnerCandidate("s1", "admin")); + Assert.IsTrue(await _service.IsOwnerCandidate("s1", "manager")); + } + + [TestMethod] + public async Task IsOwnerCandidate_FalseForRegisteredCustomer() + { + Assert.IsFalse(await _service.IsOwnerCandidate("s1", "registered")); + } + + [TestMethod] + public async Task IsOwnerCandidate_FalseForManagerOfAnotherStore() + { + Assert.IsFalse(await _service.IsOwnerCandidate("s1", "other-manager")); + } + + [TestMethod] + public async Task IsOwnerCandidate_FalseForInactiveOrDeletedAccount() + { + Assert.IsFalse(await _service.IsOwnerCandidate("s1", "inactive-admin")); + Assert.IsFalse(await _service.IsOwnerCandidate("s1", "deleted-admin")); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Validators/StoreValidatorOwnerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Validators/StoreValidatorOwnerTests.cs new file mode 100644 index 000000000..55c6419e6 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Validators/StoreValidatorOwnerTests.cs @@ -0,0 +1,61 @@ +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Stores; +using Grand.Web.AdminShared.Validators.Stores; +using Grand.Web.Common.Localization; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Validators; + +[TestClass] +public class StoreValidatorOwnerTests +{ + private const string InvalidText = "owner not allowed"; + + private StoreValidator _validator; + + [TestInitialize] + public void Init() + { + var translation = new Mock(); + translation.Setup(x => x.GetResource(It.IsAny())).Returns((string key) => key); + translation.Setup(x => x.GetResource("Admin.Configuration.Stores.Fields.OwnerCustomerId.Invalid")) + .Returns(InvalidText); + var storeService = new Mock(); + storeService.Setup(x => x.IsOwnerCandidate("s1", "admin")).ReturnsAsync(true); + + _validator = new StoreValidator([], translation.Object, storeService.Object); + } + + private static StoreModel Model(string ownerCustomerId) + { + return new StoreModel { + Id = "s1", Name = "Store", Shortcut = "st", Url = "https://store.example/", + OwnerCustomerId = ownerCustomerId + }; + } + + [TestMethod] + public async Task Validator_EmptyOwner_IsValid() + { + var result = await _validator.ValidateAsync(Model(null)); + + Assert.IsTrue(result.IsValid); + } + + [TestMethod] + public async Task Validator_Candidate_IsValid() + { + var result = await _validator.ValidateAsync(Model("admin")); + + Assert.IsTrue(result.IsValid); + } + + [TestMethod] + public async Task Validator_OwnerNotACandidate_Fails() + { + var result = await _validator.ValidateAsync(Model("posted-id")); + + Assert.IsTrue(result.Errors.Any(x => x.ErrorMessage == InvalidText)); + } +} diff --git a/src/Tests/Grand.Web.Tests/Commands/Orders/OrderApprovalCommandsTests.cs b/src/Tests/Grand.Web.Tests/Commands/Orders/OrderApprovalCommandsTests.cs index a9e5f97d7..559c3d1bd 100644 --- a/src/Tests/Grand.Web.Tests/Commands/Orders/OrderApprovalCommandsTests.cs +++ b/src/Tests/Grand.Web.Tests/Commands/Orders/OrderApprovalCommandsTests.cs @@ -1,5 +1,7 @@ +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.Common.Stores; using Grand.Business.Core.Interfaces.Catalog.Prices; using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Catalog.Tax; @@ -42,7 +44,7 @@ public class OrderApprovalCommandsTests private Mock _customers; private Mock _orders; private Mock _mediator; - private Mock> _logger; + private Mock> _logger; private Order _order; private static IRequestTranslationService Translation() @@ -75,10 +77,22 @@ private static Customer SubAccount(string id, SubAccountRole role, string email public void Init() { _approvals = new Mock(); + _approvals.Setup(a => a.Close(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync((OrderApproval a, OrderApprovalStatus status, string decidedBy, string comment) => + { + a.Status = status; + a.DecidedOnUtc = DateTime.UtcNow; + a.DecidedByCustomerId = decidedBy; + a.DecisionComment = comment; + return true; + }); + _approvals.Setup(a => a.Reopen(It.IsAny())) + .Callback(a => a.Status = OrderApprovalStatus.Pending).Returns(Task.CompletedTask); _messages = new Mock(); _customers = new Mock(); _mediator = new Mock(); - _logger = new Mock>(); + _logger = new Mock>(); _order = new Order { Id = "o1", CustomerId = "buyer", OwnerId = OwnerId, StoreId = StoreId, OrderStatusId = (int)OrderStatusSystem.AwaitingApproval @@ -94,8 +108,17 @@ public void Init() 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); + var stores = new Mock(); + stores.Setup(s => s.GetStoreById(StoreId)).ReturnsAsync(Shop()); + + // the decision itself lives in the shared command; these tests keep exercising it through the storefront + var apply = new ApplyOrderApprovalDecisionCommandHandler(_approvals.Object, _customers.Object, _messages.Object, + _orders.Object, stores.Object, _mediator.Object, _logger.Object); + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .Returns((IRequest command, CancellationToken token) => + apply.Handle((ApplyOrderApprovalDecisionCommand)command, token)); + + return new DecideOrderApprovalCommandHandler(_approvals.Object, _mediator.Object); } private static DecideOrderApprovalCommand DecideCommand(Customer approver, bool approve = true, @@ -118,7 +141,6 @@ public async Task Decide_Approver_Approves() 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); } @@ -150,7 +172,6 @@ public async Task Decide_Owner_Rejects() Assert.AreEqual(OrderApprovalStatus.Rejected, approval.Status); Assert.AreEqual(OwnerId, approval.DecidedByCustomerId); Assert.IsNull(approval.DecisionComment); - _approvals.Verify(a => a.Update(approval), Times.Once); } [TestMethod] @@ -163,7 +184,8 @@ public async Task Decide_Buyer_Refused() Assert.IsFalse(result); Assert.AreEqual(OrderApprovalStatus.Pending, approval.Status); - _approvals.Verify(a => a.Update(It.IsAny()), Times.Never); + _approvals.Verify(a => a.Close(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); _messages.Verify(m => m.SendOrderApprovalDecidedMessage(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } @@ -180,7 +202,8 @@ public async Task Decide_BuyerPromotedToApprover_CannotDecideOwnRequest() Assert.IsFalse(result); Assert.AreEqual(OrderApprovalStatus.Pending, approval.Status); - _approvals.Verify(a => a.Update(It.IsAny()), Times.Never); + _approvals.Verify(a => a.Close(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); _mediator.Verify(m => m.Send(It.IsAny(), It.IsAny()), Times.Never); } @@ -194,7 +217,8 @@ public async Task Decide_OtherCompany_Refused() Assert.IsFalse(result); _approvals.Verify(a => a.GetById("a1", "stranger"), Times.Once); - _approvals.Verify(a => a.Update(It.IsAny()), Times.Never); + _approvals.Verify(a => a.Close(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); } [TestMethod] @@ -203,7 +227,8 @@ 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); + _approvals.Verify(a => a.Close(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); } [TestMethod] @@ -220,7 +245,8 @@ public async Task Decide_NotPending_Refused(OrderApprovalStatus status) Assert.IsFalse(result); Assert.AreEqual(status, approval.Status); - _approvals.Verify(a => a.Update(It.IsAny()), Times.Never); + _approvals.Verify(a => a.Close(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); } [TestMethod] @@ -233,7 +259,8 @@ public async Task Decide_AnotherStore_Refused() var result = await Decider().Handle(DecideCommand(new Customer { Id = OwnerId }), default); Assert.IsFalse(result); - _approvals.Verify(a => a.Update(It.IsAny()), Times.Never); + _approvals.Verify(a => a.Close(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); } [TestMethod] @@ -268,6 +295,68 @@ public async Task Decide_BlankComment_IsNull(string comment) private WithdrawOrderApprovalCommandHandler Withdrawer() => new(_approvals.Object, _orders.Object, _mediator.Object, new Mock>().Object); + private OrderApproval StoreDecided(OrderApprovalStatus status = OrderApprovalStatus.Pending) + { + var approval = Approval(status); + approval.Decider = OrderApprovalDecider.Store; + approval.CompanyOwnerId = null; + _approvals.Setup(a => a.GetByOrderId("o1")).ReturnsAsync(approval); + return approval; + } + + private Task WithdrawByOrder(Customer customer) + { + return Withdrawer().Handle( + new WithdrawOrderApprovalCommand { Customer = customer, Store = Shop(), OrderId = "o1" }, default); + } + + [TestMethod] + public async Task Withdraw_ByOrderId_BuyerWithdrawsStoreDecidedRequest() + { + var approval = StoreDecided(); + var closedBeforeCancelling = false; + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .Callback(() => closedBeforeCancelling = approval.Status == OrderApprovalStatus.Withdrawn) + .ReturnsAsync(true); + + var result = await WithdrawByOrder(new Customer { Id = "buyer" }); + + Assert.IsTrue(result); + Assert.IsTrue(closedBeforeCancelling); + Assert.AreEqual(OrderApprovalStatus.Withdrawn, approval.Status); + } + + [TestMethod] + public async Task Withdraw_ByOrderId_NotTheBuyer_False() + { + var approval = StoreDecided(); + + Assert.IsFalse(await WithdrawByOrder(new Customer { Id = "someone" })); + + Assert.AreEqual(OrderApprovalStatus.Pending, approval.Status); + } + + [TestMethod] + public async Task Withdraw_ByOrderId_CompanyDecided_False() + { + var approval = Approval(); + _approvals.Setup(a => a.GetByOrderId("o1")).ReturnsAsync(approval); + + Assert.IsFalse(await WithdrawByOrder(new Customer { Id = "buyer", OwnerId = OwnerId })); + + Assert.AreEqual(OrderApprovalStatus.Pending, approval.Status); + } + + [TestMethod] + public async Task Withdraw_ByOrderId_NotPending_False() + { + var approval = StoreDecided(OrderApprovalStatus.Approved); + + Assert.IsFalse(await WithdrawByOrder(new Customer { Id = "buyer" })); + + Assert.AreEqual(OrderApprovalStatus.Approved, approval.Status); + } + [TestMethod] public async Task Withdraw_Own_Withdrawn() { @@ -279,7 +368,21 @@ public async Task Withdraw_Own_Withdrawn() Assert.IsTrue(result); Assert.AreEqual(OrderApprovalStatus.Withdrawn, approval.Status); - _approvals.Verify(a => a.Update(approval), Times.Once); + } + + [TestMethod] + public async Task Withdraw_ADecisionWonTheRace_OrderIsNotCancelled() + { + var sent = Sent(); + _approvals.Setup(a => a.GetById("a1", OwnerId)).ReturnsAsync(Approval()); + _approvals.Setup(a => a.Close(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).ReturnsAsync(false); + + var result = await Withdrawer().Handle( + new WithdrawOrderApprovalCommand { Customer = Buyer(), Store = Shop(), ApprovalId = "a1" }, default); + + Assert.IsFalse(result); + Assert.AreEqual(0, sent.Count); } [TestMethod] @@ -294,7 +397,8 @@ public async Task Withdraw_SomeoneElses_Refused() Assert.IsFalse(result); Assert.AreEqual(OrderApprovalStatus.Pending, approval.Status); - _approvals.Verify(a => a.Update(It.IsAny()), Times.Never); + _approvals.Verify(a => a.Close(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); } [TestMethod] @@ -311,7 +415,8 @@ public async Task Withdraw_Finished_Refused(OrderApprovalStatus status) Assert.IsFalse(result); Assert.AreEqual(status, approval.Status); - _approvals.Verify(a => a.Update(It.IsAny()), Times.Never); + _approvals.Verify(a => a.Close(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); } [TestMethod] @@ -374,18 +479,19 @@ public async Task Approve_AddsOneOrderApprovedNote_HiddenFromTheCustomer() 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); + n.OrderId == _order.Id && n.Note == $"Order approved by {OwnerId}" && !n.DisplayToCustomer)), Times.Once); } [TestMethod] - public async Task Reject_AddsNoOrderApprovedNote() + public async Task Reject_AddsOneOrderRejectedNote_HiddenFromTheCustomer() { 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); + _orders.Verify(o => o.InsertOrderNote(It.Is(n => + n.OrderId == _order.Id && n.Note == $"Order rejected by {OwnerId}" && !n.DisplayToCustomer)), Times.Once); } [TestMethod] @@ -413,7 +519,8 @@ public async Task Approve_OrderNoLongerAwaiting_Refused() Assert.IsFalse(result); Assert.AreEqual(OrderApprovalStatus.Pending, approval.Status); Assert.AreEqual(0, sent.Count); - _approvals.Verify(a => a.Update(It.IsAny()), Times.Never); + _approvals.Verify(a => a.Close(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); } [TestMethod] @@ -425,7 +532,8 @@ public async Task Approve_OrderMissing_Refused() var result = await Decider().Handle(DecideCommand(new Customer { Id = OwnerId }), default); Assert.IsFalse(result); - _approvals.Verify(a => a.Update(It.IsAny()), Times.Never); + _approvals.Verify(a => a.Close(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); } [TestMethod] @@ -479,7 +587,7 @@ public async Task Reject_CancelThrows_ApprovalStaysPendingAndFailureIsReported() Assert.IsFalse(result); Assert.AreEqual(OrderApprovalStatus.Pending, approval.Status); - _approvals.Verify(a => a.Update(It.IsAny()), Times.Never); + _approvals.Verify(a => a.Reopen(approval), Times.Once); _messages.Verify(m => m.SendOrderApprovalDecidedMessage(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } @@ -497,7 +605,7 @@ public async Task Withdraw_CancelThrows_ApprovalStaysPendingAndFailureIsReported Assert.IsFalse(result); Assert.AreEqual(OrderApprovalStatus.Pending, approval.Status); - _approvals.Verify(a => a.Update(It.IsAny()), Times.Never); + _approvals.Verify(a => a.Reopen(approval), Times.Once); } [TestMethod] @@ -513,7 +621,7 @@ public async Task Approve_SetStatusThrows_ApprovalStaysPending() Assert.IsFalse(result); Assert.AreEqual(OrderApprovalStatus.Pending, approval.Status); - _approvals.Verify(a => a.Update(It.IsAny()), Times.Never); + _approvals.Verify(a => a.Reopen(approval), Times.Once); Assert.AreEqual(0, sent.Count); } @@ -658,8 +766,17 @@ 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); + _approvals.Setup(a => a.Close(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync((OrderApproval a, OrderApprovalStatus status, string decidedBy, string _) => + { + if (_stored.Status != OrderApprovalStatus.Pending) + return false; + a.Status = status; + a.DecidedByCustomerId = decidedBy; + _stored = Copy(a); + return true; + }); var cancelled = new Grand.Business.Checkout.Events.Orders.OrderCancelledOrderApprovalHandler(_approvals.Object, new Mock>().Object); _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) @@ -674,7 +791,7 @@ await cancelled.Handle(new Grand.Business.Core.Events.Checkout.Orders.OrderCance } [TestMethod] - public async Task Decide_Reject_StoresRejectedOverTheCancelClose() + public async Task Decide_Reject_TheCancelLeavesTheRejectionAlone() { StoreBehindTheCancel(); @@ -683,11 +800,12 @@ public async Task Decide_Reject_StoresRejectedOverTheCancelClose() Assert.IsTrue(result); Assert.AreEqual(OrderApprovalStatus.Rejected, _stored.Status); Assert.AreEqual(OwnerId, _stored.DecidedByCustomerId); - _approvals.Verify(a => a.Update(It.IsAny()), Times.Exactly(2)); + _approvals.Verify(a => a.Close(It.IsAny(), OrderApprovalStatus.Cancelled, It.IsAny(), + It.IsAny()), Times.Never); } [TestMethod] - public async Task Withdraw_StoresWithdrawnOverTheCancelClose() + public async Task Withdraw_TheCancelLeavesTheWithdrawalAlone() { StoreBehindTheCancel(); @@ -696,7 +814,8 @@ public async Task Withdraw_StoresWithdrawnOverTheCancelClose() Assert.IsTrue(result); Assert.AreEqual(OrderApprovalStatus.Withdrawn, _stored.Status); - _approvals.Verify(a => a.Update(It.IsAny()), Times.Exactly(2)); + _approvals.Verify(a => a.Close(It.IsAny(), OrderApprovalStatus.Cancelled, It.IsAny(), + It.IsAny()), Times.Never); } #endregion diff --git a/src/Tests/Grand.Web.Tests/Controllers/CheckoutControllerConfirmOrderTests.cs b/src/Tests/Grand.Web.Tests/Controllers/CheckoutControllerConfirmOrderTests.cs index bf1af8b4a..d3f1477ec 100644 --- a/src/Tests/Grand.Web.Tests/Controllers/CheckoutControllerConfirmOrderTests.cs +++ b/src/Tests/Grand.Web.Tests/Controllers/CheckoutControllerConfirmOrderTests.cs @@ -20,6 +20,8 @@ using Grand.Web.Common.Localization; using Grand.Web.Controllers; using Grand.Web.Features.Models.Checkout; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -67,8 +69,11 @@ public void Init() new Mock().Object, new Mock().Object, new Mock>().Object, new Mock().Object, _mediator.Object, new Mock().Object, new Mock().Object, - new Mock().Object, new OrderSettings(), new OidcLoginConfig(), - new LoyaltyPointsSettings(), new PaymentSettings(), new ShippingSettings(), new AddressSettings()); + new Mock().Object, new Mock().Object, new OrderSettings(), + new OidcLoginConfig(), + new LoyaltyPointsSettings(), new PaymentSettings(), new ShippingSettings(), new AddressSettings()) { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; } [TestMethod] diff --git a/src/Tests/Grand.Web.Tests/Controllers/CheckoutControllerOrderApprovalTests.cs b/src/Tests/Grand.Web.Tests/Controllers/CheckoutControllerOrderApprovalTests.cs index 42387b12c..a967f5c13 100644 --- a/src/Tests/Grand.Web.Tests/Controllers/CheckoutControllerOrderApprovalTests.cs +++ b/src/Tests/Grand.Web.Tests/Controllers/CheckoutControllerOrderApprovalTests.cs @@ -21,6 +21,7 @@ using Grand.Web.Controllers; using Grand.Web.Features.Models.Checkout; using Grand.Web.Models.Checkout; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -32,9 +33,15 @@ namespace Grand.Web.Tests.Controllers; public class CheckoutControllerOrderApprovalTests { private Mock _approval; + private List _cart; private CheckoutController _controller; private Mock _customerService; + private Language _language; + private Mock _mediator; private Mock _paymentService; + private PaymentSettings _paymentSettings; + private Mock _placement; + private Mock _shippingService; [TestInitialize] public void Init() @@ -43,7 +50,10 @@ public void Init() 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" }); + var language = new Language { Id = "en" }; + _cart = new List { new() { ProductId = "p1", Quantity = 1 } }; + _language = language; + work.Setup(w => w.WorkingLanguage).Returns(language); work.Setup(w => w.WorkingCurrency).Returns(new Currency { Id = "c1", CurrencyCode = "EUR" }); work.Setup(w => w.TaxDisplayType).Returns(TaxDisplayType.IncludingTax); var storeContext = new Mock(); @@ -52,7 +62,7 @@ public void Init() 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 cart = _cart; var cartService = new Mock(); cartService.Setup(s => s.GetShoppingCart(customer, "s1", It.IsAny())) .ReturnsAsync(cart); @@ -60,23 +70,30 @@ public void Init() 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())) + _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)); + _placement = new Mock(); + _placement.Setup(p => p.Evaluate(cart, It.IsAny(), language, "Payments.Card", + It.IsAny())) + .ReturnsAsync(new OrderPlacementEvaluation(OrderPlacementDecision.RequireApproval, ["over the limit"])); _customerService = new Mock(); _paymentService = new Mock(); + _shippingService = new Mock(); + _paymentSettings = new PaymentSettings(); _controller = new CheckoutController(context.Object, translation.Object, _customerService.Object, - new Mock().Object, cartService.Object, new Mock().Object, + new Mock().Object, cartService.Object, _shippingService.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()); + new Mock().Object, _mediator.Object, new Mock().Object, + new Mock().Object, _approval.Object, _placement.Object, new OrderSettings(), + new OidcLoginConfig(), + new LoyaltyPointsSettings(), _paymentSettings, new ShippingSettings(), new AddressSettings()) { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; } [TestMethod] @@ -93,4 +110,113 @@ public async Task SavePaymentMethod_ApprovalRequiredAndMethodNotAllowed_RefusesW _customerService.Verify(c => c.UpdateUserField(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } + + [TestMethod] + public async Task SavePaymentMethod_Blocked_ReturnsReasons() + { + Blocks("blocked customer", "banned product"); + + 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, "blocked customer banned product"); + _paymentService.Verify(p => p.LoadPaymentMethodBySystemName(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task SavePaymentMethod_PassesTheRequestTokenToTheRules() + { + using var source = new CancellationTokenSource(); + _controller.ControllerContext.HttpContext.RequestAborted = source.Token; + + await _controller.SavePaymentMethod(new CheckoutPaymentMethodModel { PaymentMethod = "Payments.Card" }); + + _placement.Verify(p => p.Evaluate(_cart, It.IsAny(), _language, "Payments.Card", + source.Token), Times.Once); + } + + private void Blocks(params string[] reasons) + { + _placement.Setup(p => p.Evaluate(_cart, It.IsAny(), _language, "Payments.Card", + It.IsAny())) + .ReturnsAsync(new OrderPlacementEvaluation(OrderPlacementDecision.Block, reasons)); + } + + /// + /// Saves the shipping method of a checkout with one payment method, whose rules get to answer for it + /// + private async Task SaveShippingMethodWithOnlyTheCardToPayWith() + { + _paymentSettings.SkipPaymentIfOnlyOne = true; + _paymentSettings.ActivePaymentProviderSystemNames = ["Payments.Card"]; + _cart[0].IsShipEnabled = true; + + var shippingProvider = new Mock(); + shippingProvider.Setup(p => p.ValidateShippingForm(It.IsAny(), It.IsAny>())) + .ReturnsAsync(new List()); + _shippingService.Setup(s => s.LoadShippingRateCalculationProviderBySystemName("Flat")) + .Returns(shippingProvider.Object); + _shippingService.Setup(s => s.GetShippingOptions(It.IsAny(), _cart, It.IsAny
(), "Flat", + It.IsAny())) + .ReturnsAsync(new GetShippingOptionResponse { ShippingOptions = { new ShippingOption { Name = "Courier" } } }); + + var paymentStep = new CheckoutPaymentMethodModel(); + paymentStep.PaymentMethods.Add(new CheckoutPaymentMethodModel.PaymentMethodModel { + PaymentMethodSystemName = "Payments.Card" + }); + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(paymentStep); + + var provider = new Mock(); + provider.Setup(p => p.SystemName).Returns("Payments.Card"); + provider.Setup(p => p.LimitedToStores).Returns([]); + provider.Setup(p => p.SkipPaymentInfo()).ReturnsAsync(true); + _paymentService.Setup(p => p.LoadPaymentMethodBySystemName("Payments.Card")).Returns(provider.Object); + + var result = await _controller.SaveShippingMethod(new CheckoutShippingMethodModel { + ShippingOption = "Courier:Flat" + }); + return JsonSerializer.Serialize(((JsonResult)result).Value); + } + + private void CardWasNotSelected() + { + _customerService.Verify(c => c.UpdateUserField(It.IsAny(), SystemCustomerFieldNames.SelectedPaymentMethod, + "Payments.Card", It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task OnlyPaymentMethod_ApprovalRequiredAndNotAllowed_ShowsThePaymentStepInsteadOfADeadEnd() + { + _approval.Setup(a => a.IsPaymentMethodAllowed("Payments.Card")).Returns(false); + + var json = await SaveShippingMethodWithOnlyTheCardToPayWith(); + + StringAssert.Contains(json, "\"goto_section\":\"payment_method\""); + CardWasNotSelected(); + } + + [TestMethod] + public async Task OnlyPaymentMethod_BlockedForThatMethod_ShowsThePaymentStepInsteadOfADeadEnd() + { + Blocks("not with this method"); + + var json = await SaveShippingMethodWithOnlyTheCardToPayWith(); + + StringAssert.Contains(json, "\"goto_section\":\"payment_method\""); + CardWasNotSelected(); + } + + [TestMethod] + public async Task OnlyPaymentMethod_Accepted_IsSelectedAndTheStepSkipped() + { + _approval.Setup(a => a.IsPaymentMethodAllowed("Payments.Card")).Returns(true); + + var json = await SaveShippingMethodWithOnlyTheCardToPayWith(); + + StringAssert.Contains(json, "\"goto_section\":\"confirm_order\""); + _customerService.Verify(c => c.UpdateUserField(It.IsAny(), SystemCustomerFieldNames.SelectedPaymentMethod, + "Payments.Card", "s1"), Times.Once); + } } diff --git a/src/Tests/Grand.Web.Tests/Controllers/OrderControllerWithdrawApprovalTests.cs b/src/Tests/Grand.Web.Tests/Controllers/OrderControllerWithdrawApprovalTests.cs new file mode 100644 index 000000000..62f91c4c8 --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Controllers/OrderControllerWithdrawApprovalTests.cs @@ -0,0 +1,110 @@ +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Checkout.Payments; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Domain.Customers; +using Grand.Domain.Orders; +using Grand.Infrastructure; +using Grand.Mediator; +using Grand.Web.Commands.Models.Orders; +using Grand.Web.Common.Localization; +using Grand.Web.Controllers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Tests.Controllers; + +[TestClass] +public class OrderControllerWithdrawApprovalTests +{ + private readonly Customer _buyer = new() { Id = "buyer" }; + private OrderController _controller; + private Mock _mediator; + private Mock _orders; + private Mock _translation; + + [TestInitialize] + public void Init() + { + var work = new Mock(); + work.Setup(x => x.CurrentCustomer).Returns(_buyer); + var storeContext = new Mock(); + storeContext.Setup(x => x.CurrentStore).Returns(new Grand.Domain.Stores.Store { Id = "s1" }); + var context = new Mock(); + context.Setup(x => x.WorkContext).Returns(work.Object); + context.Setup(x => x.StoreContext).Returns(storeContext.Object); + + _orders = new Mock(); + _mediator = new Mock(); + _translation = new Mock(); + _translation.Setup(x => x.GetResource(It.IsAny())).Returns("text"); + + //a customer without sub-accounts counts as an owner, as GroupService answers it + var groupService = new Mock(); + groupService.Setup(x => x.IsOwner(_buyer)).ReturnsAsync(true); + + _controller = new OrderController(_orders.Object, context.Object, new Mock().Object, + new Mock().Object, _translation.Object, groupService.Object, _mediator.Object, + new OrderSettings()); + + var httpContext = new DefaultHttpContext(); + var loggerFactory = new Mock(); + loggerFactory.Setup(x => x.CreateLogger(It.IsAny())).Returns(new Mock().Object); + var urlHelperFactory = new Mock(); + urlHelperFactory.Setup(x => x.GetUrlHelper(It.IsAny())).Returns(new Mock().Object); + var services = new Mock(); + services.Setup(x => x.GetService(typeof(ILoggerFactory))).Returns(loggerFactory.Object); + services.Setup(x => x.GetService(typeof(IUrlHelperFactory))).Returns(urlHelperFactory.Object); + httpContext.RequestServices = services.Object; + _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + _controller.TempData = new TempDataDictionary(httpContext, new Mock().Object); + } + + private void Withdraws(bool answer) + { + _orders.Setup(x => x.GetOrderById("o1")).ReturnsAsync(new Order { Id = "o1", CustomerId = "buyer" }); + _mediator.Setup(x => x.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(answer); + } + + [TestMethod] + public async Task WithdrawApproval_NoOrderAccess_Challenges() + { + _orders.Setup(x => x.GetOrderById("o1")).ReturnsAsync(new Order { Id = "o1", CustomerId = "someone-else" }); + + var result = await _controller.WithdrawApproval("o1"); + + Assert.IsInstanceOfType(result); + _mediator.Verify(x => x.Send(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task WithdrawApproval_RunsWithSubAccountsFeatureOff() + { + //the action has no CustomerSettings gate: a store-decided request exists precisely when the feature is off + Withdraws(true); + + var result = await _controller.WithdrawApproval("o1"); + + Assert.AreEqual("OrderDetails", ((RedirectToRouteResult)result).RouteName); + _mediator.Verify(x => x.Send(It.Is(c => + c.OrderId == "o1" && c.Customer == _buyer && c.Store.Id == "s1"), It.IsAny()), Times.Once); + _translation.Verify(x => x.GetResource("Order.Approval.Withdrawn"), Times.Once); + } + + [TestMethod] + public async Task WithdrawApproval_Refused_ShowsTheError() + { + Withdraws(false); + + var result = await _controller.WithdrawApproval("o1"); + + Assert.AreEqual("OrderDetails", ((RedirectToRouteResult)result).RouteName); + _translation.Verify(x => x.GetResource("Order.Approval.WithdrawRefused"), Times.Once); + } +} diff --git a/src/Tests/Grand.Web.Tests/Features/Handlers/Checkout/CheckoutOrderApprovalHandlersTests.cs b/src/Tests/Grand.Web.Tests/Features/Handlers/Checkout/CheckoutOrderApprovalHandlersTests.cs index f3cf7686b..fb9f4edd3 100644 --- a/src/Tests/Grand.Web.Tests/Features/Handlers/Checkout/CheckoutOrderApprovalHandlersTests.cs +++ b/src/Tests/Grand.Web.Tests/Features/Handlers/Checkout/CheckoutOrderApprovalHandlersTests.cs @@ -9,6 +9,7 @@ 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.Tax; @@ -29,7 +30,9 @@ public class CheckoutOrderApprovalHandlersTests private Mock _credit; private Currency _currency; private Customer _customer; + private readonly Language _language = new() { Id = "pl" }; private Mock _paymentService; + private Mock _placement; private Grand.Domain.Stores.Store _store; private Mock _translation; @@ -46,14 +49,20 @@ public void Init() _credit.Setup(c => c.GetCompanyCredit(_customer)).ReturnsAsync(CompanyCredit.None("owner")); _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"); + _translation.Setup(t => t.GetResource("Checkout.OrderApproval.WillBeSentGeneric")).Returns("Goes for approval."); _translation.Setup(t => t.GetResource("Checkout.TradeCredit.Available")).Returns("Available {0} of {1}."); _translation.Setup(t => t.GetResource("Checkout.TradeCredit.Overdue")).Returns("Overdue."); + _placement = new Mock(); } - 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 void Evaluates(OrderPlacementDecision decision, params string[] reasons) => + _placement.Setup(a => a.Evaluate(_cart, It.Is(c => c.Customer == _customer), _language, + It.IsAny(), It.IsAny())) + .ReturnsAsync(new OrderPlacementEvaluation(decision, reasons)); + + private void State(bool required) => + Evaluates(required ? OrderPlacementDecision.RequireApproval : OrderPlacementDecision.Allow, + required ? ["Over the limit."] : []); private static Mock Provider(string systemName) { @@ -81,15 +90,32 @@ private GetPaymentMethodHandler PaymentHandler(params string[] methods) return new GetPaymentMethodHandler(new Mock().Object, new Mock().Object, new Mock().Object, currencyService.Object, priceFormatter.Object, _paymentService.Object, - tax.Object, _approval.Object, _credit.Object, _translation.Object, new LoyaltyPointsSettings(), + tax.Object, _approval.Object, _placement.Object, _credit.Object, _translation.Object, + new LoyaltyPointsSettings(), new PaymentSettings()); } private GetPaymentMethod PaymentRequest() => new() { Customer = _customer, Store = _store, Currency = _currency, TaxDisplayType = TaxDisplayType.IncludingTax, - Cart = _cart + Cart = _cart, Language = _language }; + [TestMethod] + public async Task GetPaymentMethod_Blocked_NoMethodsAndReasonsInMessage() + { + //no method is chosen yet at this step + _placement.Setup(a => a.Evaluate(_cart, It.IsAny(), _language, null, + It.IsAny())) + .ReturnsAsync(new OrderPlacementEvaluation(OrderPlacementDecision.Block, ["Blocked customer.", "Banned product."])); + _approval.Setup(a => a.IsPaymentMethodAllowed("Payments.CashOnDelivery")).Returns(true); + + var model = await PaymentHandler("Payments.Card", "Payments.CashOnDelivery") + .Handle(PaymentRequest(), CancellationToken.None); + + Assert.IsEmpty(model.PaymentMethods); + Assert.AreEqual("Blocked customer. Banned product.", model.NoPaymentMethodMessage); + } + [TestMethod] public async Task PaymentMethods_ApprovalRequired_KeepsOnlyAllowedMethods() { @@ -165,43 +191,77 @@ public async Task PaymentMethods_TradeCreditOverdue_AddsTheOverdueSentence() 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()); + new Mock().Object, _placement.Object, _translation.Object, new OrderSettings(), + new ShippingSettings()); } private GetConfirmOrder ConfirmRequest() => new() { Customer = _customer, Store = _store, Currency = _currency, TaxDisplayType = TaxDisplayType.IncludingTax, - Cart = _cart + Cart = _cart, Language = _language }; [TestMethod] - public async Task Confirm_ApprovalRequired_CarriesTheMessageWithTheFormattedLimitAndTheButtonText() + public async Task GetConfirmOrder_ApprovalRequired_MessageHoldsGenericLineAndReasons() { - State(true, 100); + Evaluates(OrderPlacementDecision.RequireApproval, "Over the limit.", "Restricted goods."); var model = await ConfirmHandler().Handle(ConfirmRequest(), CancellationToken.None); Assert.IsTrue(model.OrderApprovalRequired); - Assert.AreEqual("Over 200 EUR goes for approval", model.OrderApprovalMessage); + Assert.AreEqual("Goes for approval. Over the limit. Restricted goods.", model.OrderApprovalMessage); Assert.AreEqual("Checkout.OrderApproval.SendButton", model.OrderApprovalButtonText); + Assert.IsFalse(model.OrderPlacementBlocked); } [TestMethod] - public async Task Confirm_BrokenPlaceholderInTheEditedText_StillRendersTheText() + public async Task GetConfirmOrder_Blocked_SetsBlockedFlagAndMessage() { - _translation.Setup(t => t.GetResource("Checkout.OrderApproval.WillBeSent")).Returns("Over {0 goes"); - State(true, 100); + Evaluates(OrderPlacementDecision.Block, "Blocked customer.", "Banned product."); var model = await ConfirmHandler().Handle(ConfirmRequest(), CancellationToken.None); - Assert.IsTrue(model.OrderApprovalRequired); - Assert.AreEqual("Over {0 goes", model.OrderApprovalMessage); + Assert.IsTrue(model.OrderPlacementBlocked); + Assert.AreEqual("Blocked customer. Banned product.", model.OrderPlacementBlockedMessage); + Assert.IsFalse(model.OrderApprovalRequired); + } + + [TestMethod] + public async Task GetConfirmOrder_PassesTheSelectedPaymentMethodToTheRules() + { + SelectPaymentMethod("Payments.Card", PurchaseOrderNumberMode.Disabled); + _placement.Setup(a => a.Evaluate(_cart, It.IsAny(), _language, "Payments.Card", + It.IsAny())) + .ReturnsAsync(new OrderPlacementEvaluation(OrderPlacementDecision.Block, ["Not with this method."])); + + var model = await ConfirmHandler().Handle(ConfirmRequest(), CancellationToken.None); + + Assert.AreEqual("Not with this method.", model.OrderPlacementBlockedMessage); + } + + [TestMethod] + public async Task GetConfirmOrder_PassesItsTokenToTheRules() + { + using var source = new CancellationTokenSource(); + SelectPaymentMethod("Payments.Card", PurchaseOrderNumberMode.Disabled); + State(false); + + await ConfirmHandler().Handle(ConfirmRequest(), source.Token); + + _placement.Verify(a => a.Evaluate(_cart, It.IsAny(), _language, "Payments.Card", + source.Token), Times.Once); + } + + [TestMethod] + public async Task GetPaymentMethod_PassesItsTokenToTheRules() + { + using var source = new CancellationTokenSource(); + State(false); + + await PaymentHandler("Payments.Card").Handle(PaymentRequest(), source.Token); + + _placement.Verify(a => a.Evaluate(_cart, It.IsAny(), _language, null, source.Token), + Times.Once); } private void SelectPaymentMethod(string systemName, PurchaseOrderNumberMode mode) diff --git a/src/Tests/Grand.Web.Tests/Features/Handlers/Orders/GetOrderApprovalHandlersTests.cs b/src/Tests/Grand.Web.Tests/Features/Handlers/Orders/GetOrderApprovalHandlersTests.cs index 23712ad8c..b2c2f371a 100644 --- a/src/Tests/Grand.Web.Tests/Features/Handlers/Orders/GetOrderApprovalHandlersTests.cs +++ b/src/Tests/Grand.Web.Tests/Features/Handlers/Orders/GetOrderApprovalHandlersTests.cs @@ -67,6 +67,18 @@ private void Found(OrderApproval approval) //details + [TestMethod] + public async Task GetOrderApproval_CopiesReasons() + { + var approval = Approval(); + approval.Reasons = ["over the limit"]; + Found(approval); + + var model = await Details(new Customer { Id = "owner" }); + + CollectionAssert.AreEqual(new[] { "over the limit" }, model.Reasons.ToArray()); + } + [TestMethod] public async Task Details_AskedWithTheViewersCompany() { diff --git a/src/Tests/Grand.Web.Tests/Features/Handlers/Orders/GetOrderDetailsHandlerApprovalTests.cs b/src/Tests/Grand.Web.Tests/Features/Handlers/Orders/GetOrderDetailsHandlerApprovalTests.cs new file mode 100644 index 000000000..2dac56e86 --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Features/Handlers/Orders/GetOrderDetailsHandlerApprovalTests.cs @@ -0,0 +1,111 @@ +using Grand.Business.Core.Interfaces.Catalog.Prices; +using Grand.Business.Core.Interfaces.Catalog.Products; +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.Checkout.Shipping; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Storage; +using Grand.Domain.Catalog; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Domain.Localization; +using Grand.Domain.Orders; +using Grand.Domain.Tax; +using Grand.Mediator; +using Grand.Web.Common.Localization; +using Grand.Web.Features.Handlers.Orders; +using Grand.Web.Features.Models.Orders; +using Grand.Web.Models.Orders; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Tests.Features.Handlers.Orders; + +[TestClass] +public class GetOrderDetailsHandlerApprovalTests +{ + private OrderApproval _approval; + private GetOrderDetailsHandler _handler; + + [TestInitialize] + public void Init() + { + _approval = new OrderApproval { + OrderId = "o1", CustomerId = "buyer", Decider = OrderApprovalDecider.Store, + Status = OrderApprovalStatus.Pending, Reasons = ["over the limit"] + }; + var approvals = new Mock(); + approvals.Setup(x => x.GetByOrderId("o1")).ReturnsAsync(() => _approval); + + var dateTimeService = new Mock(); + dateTimeService.Setup(x => x.ConvertToUserTime(It.IsAny(), It.IsAny())) + .Returns((DateTime d, DateTimeKind _) => d); + var giftVoucherService = new Mock(); + giftVoucherService.Setup(x => x.GetAllGiftVoucherUsageHistory(It.IsAny())) + .ReturnsAsync(new List()); + var orderService = new Mock(); + orderService.Setup(x => x.GetOrderNotes(It.IsAny())).ReturnsAsync(new List()); + + _handler = new GetOrderDetailsHandler(dateTimeService.Object, new Mock().Object, + new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, + new Mock().Object, giftVoucherService.Object, + orderService.Object, new Mock().Object, + new Mock().Object, new Mock().Object, + new Mock().Object, new CatalogSettings(), new OrderSettings(), new PdfSettings(), + new TaxSettings(), new Mock().Object, approvals.Object); + } + + private Task Details(string customerId) + { + return _handler.Handle(new GetOrderDetails { + Order = new Order { Id = "o1", CustomerId = "buyer" }, + Customer = new Customer { Id = customerId }, + Language = new Language { Id = "en" } + }, CancellationToken.None); + } + + [TestMethod] + public async Task GetOrderDetails_StoreDecidedPending_BuyerCanWithdrawAndSeesReasons() + { + var model = await Details("buyer"); + + Assert.IsTrue(model.CanWithdrawOrderApproval); + CollectionAssert.AreEqual(new[] { "over the limit" }, model.OrderApprovalReasons.ToArray()); + } + + [TestMethod] + public async Task GetOrderDetails_StoreDecidedPending_OtherViewerCannotWithdraw() + { + var model = await Details("company-owner"); + + Assert.IsFalse(model.CanWithdrawOrderApproval); + } + + [TestMethod] + public async Task GetOrderDetails_CompanyDecided_ModelUnchanged() + { + _approval.Decider = OrderApprovalDecider.Company; + _approval.DecisionComment = "from the company"; + + var model = await Details("buyer"); + + Assert.IsFalse(model.CanWithdrawOrderApproval); + Assert.AreEqual(0, model.OrderApprovalReasons.Count); + Assert.IsNull(model.OrderApprovalComment); + } + + [TestMethod] + public async Task GetOrderDetails_Decided_ShowsCommentNotWithdraw() + { + _approval.Status = OrderApprovalStatus.Rejected; + _approval.DecisionComment = "not this quarter"; + + var model = await Details("buyer"); + + Assert.IsFalse(model.CanWithdrawOrderApproval); + Assert.AreEqual("not this quarter", model.OrderApprovalComment); + Assert.AreEqual(0, model.OrderApprovalReasons.Count); + } +} diff --git a/src/Tests/Grand.Web.Tests/Features/Handlers/Orders/GetOrderDetailsHandlerPaymentTermsTests.cs b/src/Tests/Grand.Web.Tests/Features/Handlers/Orders/GetOrderDetailsHandlerPaymentTermsTests.cs index 2d93946da..5e1bbcea9 100644 --- a/src/Tests/Grand.Web.Tests/Features/Handlers/Orders/GetOrderDetailsHandlerPaymentTermsTests.cs +++ b/src/Tests/Grand.Web.Tests/Features/Handlers/Orders/GetOrderDetailsHandlerPaymentTermsTests.cs @@ -48,7 +48,7 @@ public void Init() orderService.Object, new Mock().Object, new Mock().Object, new Mock().Object, new Mock().Object, new CatalogSettings(), new OrderSettings(), new PdfSettings(), - new TaxSettings(), new Mock().Object); + new TaxSettings(), new Mock().Object, new Mock().Object); } private Task Details(Order order) diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Store/Partials/CreateOrUpdate.TabInfo.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Store/Partials/CreateOrUpdate.TabInfo.cshtml index ff0563c7d..8d4364c38 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Store/Partials/CreateOrUpdate.TabInfo.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Store/Partials/CreateOrUpdate.TabInfo.cshtml @@ -25,6 +25,7 @@ + diff --git a/src/Web/Grand.Web.Admin/Controllers/StoreController.cs b/src/Web/Grand.Web.Admin/Controllers/StoreController.cs index 551e40ca7..9d458a913 100644 --- a/src/Web/Grand.Web.Admin/Controllers/StoreController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/StoreController.cs @@ -68,6 +68,8 @@ public async Task Create() await _storeViewModelService.PrepareCountryModel(model); //currencies await _storeViewModelService.PrepareCurrencyModel(model); + //owner account + await _storeViewModelService.PrepareOwnerAccountsModel(model); return View(model); } @@ -95,6 +97,8 @@ public async Task Create(StoreModel model, bool continueEditing) await _storeViewModelService.PrepareCountryModel(model); //currencies await _storeViewModelService.PrepareCurrencyModel(model); + //owner account + await _storeViewModelService.PrepareOwnerAccountsModel(model); //If we got this far, something failed, redisplay form return View(model); @@ -117,6 +121,8 @@ public async Task Edit(string id) await _storeViewModelService.PrepareCountryModel(model); //currencies await _storeViewModelService.PrepareCurrencyModel(model); + //owner account + await _storeViewModelService.PrepareOwnerAccountsModel(model); //locales await AddLocales(_languageService, model.Locales, (locale, languageId) => @@ -156,6 +162,8 @@ public async Task Edit(StoreModel model, bool continueEditing) await _storeViewModelService.PrepareCountryModel(model); //currencies await _storeViewModelService.PrepareCurrencyModel(model); + //owner account + await _storeViewModelService.PrepareOwnerAccountsModel(model); return View(model); } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseOrderManagementController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseOrderManagementController.cs index 7c63ef23e..976e62673 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseOrderManagementController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseOrderManagementController.cs @@ -70,6 +70,48 @@ public async Task CancelOrder(string id) } } + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task DecideApprovalPopup(string id) + { + //this is loaded into a popup: a redirect would put a whole page inside it + const string refused = "DecideApprovalRefused"; + + var (order, denied) = await LoadAuthorizedOrder(id); + if (denied != null) return View(refused); + + var model = await OrderViewModelService.PrepareOrderApprovalDecisionModel(order); + if (model == null) + return View(refused); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task DecideApproval(string id, bool? approve, string comment) + { + var (order, denied) = await LoadAuthorizedOrder(id); + if (denied != null) return denied; + + //the decision must be explicit: a missing value is never read as a rejection or an approval + var decided = approve.HasValue && await mediator.Send(new DecideStoreOrderApprovalCommand { + Order = order, + DecidedBy = ContextAccessor.WorkContext.CurrentCustomer, + Approve = approve.Value, + Comment = comment, + Language = ContextAccessor.WorkContext.WorkingLanguage + }); + + if (decided) + Success(TranslationService.GetResource(approve.Value + ? "Admin.Orders.Approval.Approved" + : "Admin.Orders.Approval.Rejected")); + else + Error(TranslationService.GetResource("Admin.Orders.Approval.Refused")); + + return RedirectToAction("Edit", "Order", new { id }); + } + [PermissionAuthorizeAction(PermissionActionName.Edit)] [HttpPost] public async Task SaveOrderTags(OrderModel orderModel) diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IOrderViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IOrderViewModelService.cs index 5ee44aeb9..111a1e17b 100644 --- a/src/Web/Grand.Web.AdminShared/Interfaces/IOrderViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IOrderViewModelService.cs @@ -12,6 +12,7 @@ Task PrepareOrderListModel(int? orderStatusId = null, int? payme int pageSize); Task PrepareOrderDetailsModel(OrderModel model, Order order); + Task PrepareOrderApprovalDecisionModel(Order order); Task> PrepareOrders(OrderListModel model); Task SaveOrderTags(Order order, string tags); } \ No newline at end of file diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IStoreViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IStoreViewModelService.cs index f740b23af..1f025ed59 100644 --- a/src/Web/Grand.Web.AdminShared/Interfaces/IStoreViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IStoreViewModelService.cs @@ -9,6 +9,8 @@ public interface IStoreViewModelService Task PrepareWarehouseModel(StoreModel model); Task PrepareCountryModel(StoreModel model); Task PrepareCurrencyModel(StoreModel model); + Task PrepareOwnerAccountsModel(StoreModel model); + Task IsOwnerCandidate(string storeId, string customerId); StoreModel PrepareStoreModel(); Task InsertStoreModel(StoreModel model); Task UpdateStoreModel(Store store, StoreModel model); diff --git a/src/Web/Grand.Web.AdminShared/Mapper/StoreProfile.cs b/src/Web/Grand.Web.AdminShared/Mapper/StoreProfile.cs index 800bc75a2..33f1fe6c6 100644 --- a/src/Web/Grand.Web.AdminShared/Mapper/StoreProfile.cs +++ b/src/Web/Grand.Web.AdminShared/Mapper/StoreProfile.cs @@ -16,6 +16,7 @@ public StoreProfile() .ForMember(dest => dest.AvailableWarehouses, mo => mo.Ignore()) .ForMember(dest => dest.AvailableCountries, mo => mo.Ignore()) .ForMember(dest => dest.AvailableCurrencies, mo => mo.Ignore()) + .ForMember(dest => dest.AvailableOwnerAccounts, mo => mo.Ignore()) .ForMember(dest => dest.Locales, mo => mo.Ignore()); CreateMap() .ForMember(dest => dest.Id, mo => mo.Ignore()) diff --git a/src/Web/Grand.Web.AdminShared/Models/Orders/OrderApprovalDecisionModel.cs b/src/Web/Grand.Web.AdminShared/Models/Orders/OrderApprovalDecisionModel.cs new file mode 100644 index 000000000..3dbb122c1 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Models/Orders/OrderApprovalDecisionModel.cs @@ -0,0 +1,11 @@ +namespace Grand.Web.AdminShared.Models.Orders; + +/// +/// The popup approving or rejecting an order +/// +public class OrderApprovalDecisionModel +{ + public string OrderId { get; set; } + public int OrderNumber { get; set; } + public IList Reasons { get; set; } = new List(); +} diff --git a/src/Web/Grand.Web.AdminShared/Models/Orders/OrderModel.cs b/src/Web/Grand.Web.AdminShared/Models/Orders/OrderModel.cs index 2b94a41b8..5bdff716c 100644 --- a/src/Web/Grand.Web.AdminShared/Models/Orders/OrderModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Orders/OrderModel.cs @@ -12,6 +12,26 @@ namespace Grand.Web.AdminShared.Models.Orders; public class OrderModel : BaseEntityModel { + /// + /// Status of the order's approval request; null when the order has none or the panel does not show it + /// + public string ApprovalStatus { get; set; } + + /// + /// Who decides the approval request + /// + public string ApprovalDecider { get; set; } + + /// + /// Why the order needs an approval + /// + public IList ApprovalReasons { get; set; } = new List(); + + /// + /// The current customer can approve or reject the order in this panel + /// + public bool CanDecideApproval { get; set; } + //identifiers [GrandResourceDisplayName("Admin.Orders.Fields.ID")] public override string Id { get; set; } diff --git a/src/Web/Grand.Web.AdminShared/Models/Stores/StoreModel.cs b/src/Web/Grand.Web.AdminShared/Models/Stores/StoreModel.cs index 711fccd98..88e98d9ca 100644 --- a/src/Web/Grand.Web.AdminShared/Models/Stores/StoreModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Stores/StoreModel.cs @@ -63,6 +63,11 @@ public class StoreModel : BaseEntityModel, ILocalizedModel public IList AvailableCurrencies { get; set; } = new List(); + [GrandResourceDisplayName("Admin.Configuration.Stores.Fields.OwnerCustomerId")] + public string OwnerCustomerId { get; set; } + + public IList AvailableOwnerAccounts { get; set; } = new List(); + public IList Domains { get; set; } = new List(); public BankAccountModel BankAccount { get; set; } diff --git a/src/Web/Grand.Web.AdminShared/Services/OrderViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/OrderViewModelService.cs index e63f32afe..f356a4921 100644 --- a/src/Web/Grand.Web.AdminShared/Services/OrderViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/OrderViewModelService.cs @@ -67,6 +67,7 @@ public class OrderViewModelService : IOrderViewModelService private readonly IMediator _mediator; private readonly IEnumTranslationService _enumTranslationService; private readonly IAdminDataScope _scope; + private readonly IOrderApprovalService _orderApprovalService; #endregion @@ -101,8 +102,10 @@ public OrderViewModelService(IOrderService orderService, IOrderStatusService orderStatusService, IMediator mediator, IEnumTranslationService enumTranslationService, - IAdminDataScope scope) + IAdminDataScope scope, + IOrderApprovalService orderApprovalService) { + _orderApprovalService = orderApprovalService; _orderService = orderService; _dateTimeService = dateTimeService; _priceFormatter = priceFormatter; @@ -314,6 +317,50 @@ public virtual async Task PrepareOrderListModel( } + /// + /// The order's approval request, for the panels that own the whole order: a vendor sees neither it nor its reasons + /// + public virtual async Task PrepareOrderApprovalDetails(OrderModel model, Order order) + { + if (_scope.DefaultVendorId is not null) + return; + + var approval = await _orderApprovalService.GetByOrderId(order.Id); + if (approval == null) + return; + + model.ApprovalStatus = _enumTranslationService.GetTranslationEnum(approval.Status); + model.ApprovalDecider = _enumTranslationService.GetTranslationEnum(approval.Decider); + model.ApprovalReasons = approval.Reasons; + model.CanDecideApproval = CanDecideApproval(approval, order); + } + + public virtual async Task PrepareOrderApprovalDecisionModel(Order order) + { + if (_scope.DefaultVendorId is not null) + return null; + + var approval = await _orderApprovalService.GetByOrderId(order.Id); + if (approval == null || !CanDecideApproval(approval, order)) + return null; + + return new OrderApprovalDecisionModel { + OrderId = order.Id, + OrderNumber = order.OrderNumber, + Reasons = approval.Reasons + }; + } + + /// + /// An open request the store decides; a company's stays with the company, and nobody decides their own order + /// + private bool CanDecideApproval(OrderApproval approval, Order order) + { + return approval is { Status: OrderApprovalStatus.Pending, Decider: OrderApprovalDecider.Store } && + order.OrderStatusId == (int)OrderStatusSystem.AwaitingApproval && + approval.CustomerId != _contextAccessor.WorkContext.CurrentCustomer.Id; + } + public virtual async Task PrepareOrderDetailsModel(OrderModel model, Order order) { ArgumentNullException.ThrowIfNull(order); @@ -340,6 +387,7 @@ public virtual async Task PrepareOrderDetailsModel(OrderModel model, Order order //offered to the panels that own the order, not to a vendor who sees only his own items if (_scope.DefaultVendorId is null) model.CanCancelOrder = await _mediator.Send(new CanCancelOrderQuery { Order = order }); + await PrepareOrderApprovalDetails(model, order); var store = await _storeService.GetStoreById(order.StoreId); model.StoreName = store != null ? store.Shortcut : "Unknown"; diff --git a/src/Web/Grand.Web.AdminShared/Services/StoreViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/StoreViewModelService.cs index 73b246303..c1ad8d1ff 100644 --- a/src/Web/Grand.Web.AdminShared/Services/StoreViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/StoreViewModelService.cs @@ -1,9 +1,14 @@ -using Grand.Business.Core.Interfaces.Checkout.Shipping; +using Grand.Business.Core.Extensions; +using Grand.Business.Core.Interfaces.Checkout.Shipping; using Grand.Business.Core.Interfaces.Common.Configuration; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Queries.Customers; +using Grand.Domain.Customers; using Grand.Domain.Stores; +using Grand.Mediator; using Grand.Web.AdminShared.Extensions.Mapping; using Grand.Web.AdminShared.Interfaces; using Grand.Web.AdminShared.Models.Stores; @@ -15,15 +20,22 @@ public class StoreViewModelService : IStoreViewModelService { private readonly ICountryService _countryService; private readonly ICurrencyService _currencyService; + private readonly ICustomerService _customerService; + private readonly IGroupService _groupService; private readonly ILanguageService _languageService; + private readonly IMediator _mediator; private readonly ISettingService _settingService; private readonly IStoreService _storeService; private readonly IWarehouseService _warehouseService; public StoreViewModelService(ILanguageService languageService, IWarehouseService warehouseService, IStoreService storeService, ISettingService settingService, - ICountryService countryService, ICurrencyService currencyService) + ICountryService countryService, ICurrencyService currencyService, + ICustomerService customerService, IGroupService groupService, IMediator mediator) { + _mediator = mediator; + _customerService = customerService; + _groupService = groupService; _languageService = languageService; _warehouseService = warehouseService; _storeService = storeService; @@ -101,6 +113,48 @@ public virtual async Task PrepareCurrencyModel(StoreModel model) }); } + public virtual async Task PrepareOwnerAccountsModel(StoreModel model) + { + ArgumentNullException.ThrowIfNull(model); + + model.AvailableOwnerAccounts.Add(new SelectListItem { + Text = "---", + Value = "" + }); + + foreach (var customer in await GetOwnerCandidates(model.Id)) + model.AvailableOwnerAccounts.Add(new SelectListItem { + Text = $"{customer.GetFullName()} ({customer.Email})".Trim(), + Value = customer.Id + }); + } + + public virtual async Task IsOwnerCandidate(string storeId, string customerId) + { + return (await GetOwnerCandidates(storeId)).Any(x => x.Id == customerId); + } + + /// + /// Accounts that can reach this store's orders in a panel: active administrators and the store's managers + /// + private async Task> GetOwnerCandidates(string storeId) + { + var administrators = await _groupService.GetCustomerGroupBySystemName(SystemCustomerGroupNames.Administrators); + var managers = await _groupService.GetCustomerGroupBySystemName(SystemCustomerGroupNames.StoreManager); + + var candidates = (await _customerService.GetAllCustomers(customerGroupIds: [administrators.Id])).ToList(); + if (!string.IsNullOrEmpty(storeId)) + candidates.AddRange(await _mediator.Send(new GetCustomerQuery { + CustomerGroupIds = [managers.Id], + StaffStoreId = storeId + })); + + return candidates + .Where(x => x is { Active: true, Deleted: false }) + .DistinctBy(x => x.Id) + .ToList(); + } + public virtual StoreModel PrepareStoreModel() { var model = new StoreModel(); diff --git a/src/Web/Grand.Web.AdminShared/Validators/Stores/StoreValidator.cs b/src/Web/Grand.Web.AdminShared/Validators/Stores/StoreValidator.cs index 9fecf6d6d..47479b6f7 100644 --- a/src/Web/Grand.Web.AdminShared/Validators/Stores/StoreValidator.cs +++ b/src/Web/Grand.Web.AdminShared/Validators/Stores/StoreValidator.cs @@ -1,5 +1,6 @@ using FluentValidation; using Grand.Infrastructure.Validators; +using Grand.Web.AdminShared.Interfaces; using Grand.Web.AdminShared.Models.Stores; using Grand.Web.Common.Localization; @@ -9,9 +10,14 @@ public class StoreValidator : BaseGrandValidator { public StoreValidator( IEnumerable> validators, - IRequestTranslationService translationService) + IRequestTranslationService translationService, + IStoreViewModelService storeViewModelService) : base(validators) { + //a posted id is checked against the same list the dropdown offers + RuleFor(x => x.OwnerCustomerId) + .MustAsync(async (x, id, _) => string.IsNullOrEmpty(id) || await storeViewModelService.IsOwnerCandidate(x.Id, id)) + .WithMessage(translationService.GetResource("Admin.Configuration.Stores.Fields.OwnerCustomerId.Invalid")); RuleFor(x => x.Name).NotEmpty() .WithMessage(translationService.GetResource("Admin.Configuration.Stores.Fields.Name.Required")); RuleFor(x => x.Shortcut).NotEmpty() diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Order/DecideApprovalPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Order/DecideApprovalPopup.cshtml new file mode 100644 index 000000000..895b7c286 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Order/DecideApprovalPopup.cshtml @@ -0,0 +1,40 @@ +@{ + Layout = ""; + //page title + ViewBag.Title = Loc["Admin.Orders.Approval.PopupTitle"]; + var area = ViewContext.RouteData.Values["area"]?.ToString(); + //disabled only after the submit has started: a disabled button would drop its value, which is the decision + const string submitOnce = "if (this.dataset.sent) { return false; } this.dataset.sent = '1'; var f = this; setTimeout(function () { f.querySelectorAll('button[type=submit]').forEach(function (b) { b.disabled = true; }); }, 0);"; +} +@model OrderApprovalDecisionModel +@* A plain post, unlike the ajax popups: serializing the form would drop the clicked button, which is the decision, + and the redirect has to reload the order. *@ +
+ + @if (Model.Reasons.Any()) + { +
+
+
@Loc["Admin.Orders.Approval.Reasons"]
+
+
    + @foreach (var reason in Model.Reasons) + { +
  • @reason
  • + } +
+
+
+
+ } +
+ + + @Loc["Admin.Orders.Approval.Note.Hint"] +
+
+ + +
+
+ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Order/DecideApprovalRefused.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Order/DecideApprovalRefused.cshtml new file mode 100644 index 000000000..38ea86b16 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Order/DecideApprovalRefused.cshtml @@ -0,0 +1,9 @@ +@{ + Layout = ""; + //page title + ViewBag.Title = Loc["Admin.Orders.Approval.PopupTitle"]; +} +@* What the approval popup shows when its request can no longer be decided: someone else decided it, or the buyer withdrew it *@ + +

@Loc["Admin.Orders.Approval.Refused"]

+
diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Order/Edit.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Order/Edit.cshtml index a934fa18f..dcbccfffa 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Order/Edit.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Order/Edit.cshtml @@ -23,6 +23,30 @@ back-url="@Url.Action("List")" back-text="@Loc["Admin.Orders.BackToList"]"> + @if (Model.CanDecideApproval) + { + @* the popup is appended outside order-form, so its own form does not nest *@ + + @Loc["Admin.Orders.Approval.Decide"] + + + } @Loc["Admin.Orders.PdfInvoice"] diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Order/Partials/OrderDetails.Info.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Order/Partials/OrderDetails.Info.cshtml index c034ea7fc..d98ef2e96 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Order/Partials/OrderDetails.Info.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Order/Partials/OrderDetails.Info.cshtml @@ -141,6 +141,36 @@
+@if (!string.IsNullOrEmpty(Model.ApprovalStatus)) +{ + +
+
+
@Loc["Admin.Orders.Approval.Status"]
+
@Model.ApprovalStatus
+
+
+
@Loc["Admin.Orders.Approval.Decider"]
+
@Model.ApprovalDecider
+
+ @if (Model.ApprovalReasons.Any()) + { +
+
@Loc["Admin.Orders.Approval.Reasons"]
+
+
    + @foreach (var reason in Model.ApprovalReasons) + { +
  • @reason
  • + } +
+
+
+ } +
+
+} +
@if (!string.IsNullOrEmpty(Model.Code)) diff --git a/src/Web/Grand.Web.Models/Checkout/CheckoutConfirmModel.cs b/src/Web/Grand.Web.Models/Checkout/CheckoutConfirmModel.cs index 44211f724..2ae50b914 100644 --- a/src/Web/Grand.Web.Models/Checkout/CheckoutConfirmModel.cs +++ b/src/Web/Grand.Web.Models/Checkout/CheckoutConfirmModel.cs @@ -10,15 +10,25 @@ public class CheckoutConfirmModel : BaseModel 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 + /// An order placement rule requires an approval: the order is placed at once and waits for a decision /// public bool OrderApprovalRequired { get; set; } /// - /// Tells the buyer the order goes to the company for approval, with the limit formatted + /// Tells the buyer the order goes for approval, and why /// public string OrderApprovalMessage { get; set; } + /// + /// An order placement rule blocks the order: it cannot be confirmed + /// + public bool OrderPlacementBlocked { get; set; } + + /// + /// Tells the buyer why the order cannot be placed + /// + public string OrderPlacementBlockedMessage { get; set; } + /// /// Label of the confirm button when the order needs an approval /// diff --git a/src/Web/Grand.Web.Models/Orders/OrderApprovalModel.cs b/src/Web/Grand.Web.Models/Orders/OrderApprovalModel.cs index 510e4c204..4e1215b07 100644 --- a/src/Web/Grand.Web.Models/Orders/OrderApprovalModel.cs +++ b/src/Web/Grand.Web.Models/Orders/OrderApprovalModel.cs @@ -25,6 +25,11 @@ public class OrderApprovalModel : BaseEntityModel public string DecidedBy { get; set; } public string DecisionComment { get; set; } + /// + /// Why the order needs an approval + /// + public IList Reasons { get; set; } = new List(); + /// /// The viewer is an approver of the company and the request is still pending /// diff --git a/src/Web/Grand.Web.Models/Orders/OrderDetailsModel.cs b/src/Web/Grand.Web.Models/Orders/OrderDetailsModel.cs index 6f08e19c9..9846315c4 100644 --- a/src/Web/Grand.Web.Models/Orders/OrderDetailsModel.cs +++ b/src/Web/Grand.Web.Models/Orders/OrderDetailsModel.cs @@ -10,6 +10,21 @@ public class OrderDetailsModel : BaseEntityModel public bool UserCanCancelUnpaidOrder { get; set; } + /// + /// Why the order waits for the store's decision; empty once it is decided, and for a company's request + /// + public IList OrderApprovalReasons { get; set; } = new List(); + + /// + /// The store's note on its decision + /// + public string OrderApprovalComment { get; set; } + + /// + /// The buyer can withdraw a request the store has not decided yet + /// + public bool CanWithdrawOrderApproval { get; set; } + public DateTime CreatedOn { get; set; } public string OrderStatus { 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 6feffdd12..a9d105e27 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 1a1df48f7..6a4d1bdaf 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 @@ -348,9 +348,6 @@ 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 @@ -765,6 +762,87 @@ Payment overdue + + This order cannot be placed right now. Please contact the store. + + + This order needs approval. + + + This order cannot be placed. + + + This order will be sent for approval after you confirm it. + + + The order total exceeds your order limit of {0}. + + + Owner account + + + The account told by e-mail when an order of this store waits for the store's decision; the order's sales manager, if any, is told as well. Anyone with access to the order can decide it. + + + Choose an active administrator or a manager of this store. + + + Approve or reject + + + Order approval + + + Why it needs approval + + + Note + + + Optional. The buyer sees it in the decision e-mail and on the order page. + + + Approve + + + Reject + + + The order has been approved. + + + The order has been rejected. + + + This approval can no longer be decided here. + + + Approval + + + Decided by + + + The buyer's company + + + The store + + + Why it needs approval + + + Note from the store + + + Withdraw the request and cancel the order + + + The request has been withdrawn and the order cancelled. + + + This request can no longer be withdrawn. + Server time zone diff --git a/src/Web/Grand.Web/Commands/Handler/Orders/DecideOrderApprovalCommandHandler.cs b/src/Web/Grand.Web/Commands/Handler/Orders/DecideOrderApprovalCommandHandler.cs index 62db0d1e4..a3e35a60b 100644 --- a/src/Web/Grand.Web/Commands/Handler/Orders/DecideOrderApprovalCommandHandler.cs +++ b/src/Web/Grand.Web/Commands/Handler/Orders/DecideOrderApprovalCommandHandler.cs @@ -1,39 +1,20 @@ 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) + public DecideOrderApprovalCommandHandler(IOrderApprovalService orderApprovalService, IMediator mediator) { _orderApprovalService = orderApprovalService; - _customerService = customerService; - _messageProviderService = messageProviderService; - _orderService = orderService; _mediator = mediator; - _logger = logger; } public async Task Handle(DecideOrderApprovalCommand request, CancellationToken cancellationToken) @@ -48,104 +29,16 @@ public async Task Handle(DecideOrderApprovalCommand request, CancellationT 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); + if (approval == null || approval.StoreId != request.Store.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; + return await _mediator.Send(new ApplyOrderApprovalDecisionCommand { + Approval = approval, + DecidedBy = request.Approver, + Approve = request.Approve, + Comment = request.Comment, + Language = request.Language, + Host = request.Host + }, cancellationToken); } } diff --git a/src/Web/Grand.Web/Commands/Handler/Orders/WithdrawOrderApprovalCommandHandler.cs b/src/Web/Grand.Web/Commands/Handler/Orders/WithdrawOrderApprovalCommandHandler.cs index 6a85b6bf7..4d5f5dd43 100644 --- a/src/Web/Grand.Web/Commands/Handler/Orders/WithdrawOrderApprovalCommandHandler.cs +++ b/src/Web/Grand.Web/Commands/Handler/Orders/WithdrawOrderApprovalCommandHandler.cs @@ -29,8 +29,9 @@ public async Task Handle(WithdrawOrderApprovalCommand request, Cancellatio ArgumentNullException.ThrowIfNull(request.Customer); ArgumentNullException.ThrowIfNull(request.Store); - var approval = await _orderApprovalService.GetById(request.ApprovalId, - OrderApprovalRules.CompanyId(request.Customer)); + var approval = string.IsNullOrEmpty(request.OrderId) + ? await _orderApprovalService.GetById(request.ApprovalId, OrderApprovalRules.CompanyId(request.Customer)) + : await GetStoreDecidedRequest(request.OrderId); // 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 || @@ -41,21 +42,32 @@ public async Task Handle(WithdrawOrderApprovalCommand request, Cancellatio 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 + // the request closes first: a decision taken at the same moment wins or loses as a whole + if (!await _orderApprovalService.Close(approval, OrderApprovalStatus.Withdrawn)) + return false; + + // 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", + _logger.LogError(e, "Order approval {Id}: the order could not be cancelled, the request goes back to pending", approval.Id); + await _orderApprovalService.Reopen(approval); return false; } - approval.Status = OrderApprovalStatus.Withdrawn; - await _orderApprovalService.Update(approval); return true; } + + /// + /// A company's request is withdrawn through the company's approvals screen, which also checks the company + /// + private async Task GetStoreDecidedRequest(string orderId) + { + var approval = await _orderApprovalService.GetByOrderId(orderId); + return approval is { Decider: OrderApprovalDecider.Store } ? approval : null; + } } diff --git a/src/Web/Grand.Web/Commands/Models/Orders/WithdrawOrderApprovalCommand.cs b/src/Web/Grand.Web/Commands/Models/Orders/WithdrawOrderApprovalCommand.cs index 28101850a..69de8e5aa 100644 --- a/src/Web/Grand.Web/Commands/Models/Orders/WithdrawOrderApprovalCommand.cs +++ b/src/Web/Grand.Web/Commands/Models/Orders/WithdrawOrderApprovalCommand.cs @@ -11,4 +11,9 @@ public class WithdrawOrderApprovalCommand : IRequest public Customer Customer { get; set; } public Domain.Stores.Store Store { get; set; } public string ApprovalId { get; set; } + + /// + /// Withdraws the request of this order, which the store decides, instead of the company's request ApprovalId + /// + public string OrderId { get; set; } } diff --git a/src/Web/Grand.Web/Controllers/CheckoutController.cs b/src/Web/Grand.Web/Controllers/CheckoutController.cs index e5c585514..93e2c89c1 100644 --- a/src/Web/Grand.Web/Controllers/CheckoutController.cs +++ b/src/Web/Grand.Web/Controllers/CheckoutController.cs @@ -54,6 +54,7 @@ public CheckoutController( IProductService productService, IShoppingCartValidator shoppingCartValidator, IOrderApprovalService orderApprovalService, + IOrderPlacementService orderPlacementService, OrderSettings orderSettings, OidcLoginConfig oidcLoginConfig, LoyaltyPointsSettings loyaltyPointsSettings, @@ -76,6 +77,7 @@ public CheckoutController( _productService = productService; _shoppingCartValidator = shoppingCartValidator; _orderApprovalService = orderApprovalService; + _orderPlacementService = orderPlacementService; _orderSettings = orderSettings; _oidcLoginConfig = oidcLoginConfig; _loyaltyPointsSettings = loyaltyPointsSettings; @@ -579,7 +581,7 @@ await _customerService.UpdateUserField(_contextAccessor.WorkContext.Curr var confirmOrderModel = await _mediator.Send(new GetConfirmOrder { Cart = cart, Customer = _contextAccessor.WorkContext.CurrentCustomer, Language = _contextAccessor.WorkContext.WorkingLanguage, Currency = _contextAccessor.WorkContext.WorkingCurrency, TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType, Store = _contextAccessor.StoreContext.CurrentStore - }); + }, HttpContext.RequestAborted); return Json(new { update_section = new UpdateSectionJsonModel { name = "confirm-order", @@ -589,10 +591,9 @@ 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 refusal = await GetPaymentMethodRefusal(cart, model.PaymentMethod); + if (refusal != null) + throw new Exception(refusal); var paymentMethodInst = _paymentService.LoadPaymentMethodBySystemName(model.PaymentMethod); if (paymentMethodInst == null || @@ -651,7 +652,7 @@ await _customerService.UpdateUserField(_contextAccessor.WorkContext.CurrentCusto var confirmOrderModel = await _mediator.Send(new GetConfirmOrder { Cart = cart, Customer = _contextAccessor.WorkContext.CurrentCustomer, Language = _contextAccessor.WorkContext.WorkingLanguage, Currency = _contextAccessor.WorkContext.WorkingCurrency, TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType, Store = _contextAccessor.StoreContext.CurrentStore - }); + }, HttpContext.RequestAborted); return Json(new { update_section = new UpdateSectionJsonModel { name = "confirm-order", @@ -728,6 +729,7 @@ public virtual async Task ConfirmOrder(string purchaseOrderNumber error = 1, message = _translationService.GetResource("Checkout.MinOrderPlacementInterval") }); + //not the request's token: an order being paid for must finish even if the buyer's connection drops var placeOrderResult = await _mediator.Send(new PlaceOrderCommand { Context = _contextAccessor.CalculationContext(), Language = _contextAccessor.WorkContext.WorkingLanguage, @@ -765,7 +767,7 @@ public virtual async Task ConfirmOrder(string purchaseOrderNumber var confirmOrderModel = await _mediator.Send(new GetConfirmOrder { Cart = cart, Customer = _contextAccessor.WorkContext.CurrentCustomer, Language = _contextAccessor.WorkContext.WorkingLanguage, Currency = _contextAccessor.WorkContext.WorkingCurrency, TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType, Store = _contextAccessor.StoreContext.CurrentStore - }); + }, HttpContext.RequestAborted); foreach (var error in placeOrderResult.Errors) confirmOrderModel.Warnings.Add(error); @@ -846,6 +848,7 @@ public virtual async Task CompleteRedirectionPayment(string payme private readonly IProductService _productService; private readonly IShoppingCartValidator _shoppingCartValidator; private readonly IOrderApprovalService _orderApprovalService; + private readonly IOrderPlacementService _orderPlacementService; private readonly OrderSettings _orderSettings; private readonly OidcLoginConfig _oidcLoginConfig; private readonly LoyaltyPointsSettings _loyaltyPointsSettings; @@ -958,10 +961,12 @@ private async Task LoadStepAfterShippingMethod(IList(_contextAccessor.WorkContext.Curr var confirmOrderModel = await _mediator.Send(new GetConfirmOrder { Cart = cart, Customer = _contextAccessor.WorkContext.CurrentCustomer, Language = _contextAccessor.WorkContext.WorkingLanguage, Currency = _contextAccessor.WorkContext.WorkingCurrency, TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType, Store = _contextAccessor.StoreContext.CurrentStore - }); + }, HttpContext.RequestAborted); return Json(new { update_section = new UpdateSectionJsonModel { name = "confirm-order", @@ -1006,6 +1011,24 @@ await _customerService.UpdateUserField(_contextAccessor.WorkContext.Curr }); } + /// + /// Why the order cannot go on with this payment method; null when it can. A blocked order goes no further, and + /// one that needs an approval may only use the payment methods the admin chose. + /// + [NonAction] + private async Task GetPaymentMethodRefusal(IList cart, string paymentMethod) + { + var evaluation = await _orderPlacementService.Evaluate(cart, _contextAccessor.CalculationContext(), + _contextAccessor.WorkContext.WorkingLanguage, paymentMethod, HttpContext.RequestAborted); + if (evaluation.Decision == OrderPlacementDecision.Block) + return string.Join(" ", evaluation.Reasons); + if (evaluation.Decision == OrderPlacementDecision.RequireApproval && + !_orderApprovalService.IsPaymentMethodAllowed(paymentMethod)) + return _translationService.GetResource("Checkout.OrderApproval.PaymentMethodNotAllowed"); + + return null; + } + [NonAction] private async Task LoadStepAfterPaymentMethod(IPaymentProvider paymentMethod, IList cart) @@ -1017,7 +1040,7 @@ private async Task LoadStepAfterPaymentMethod(IPaymentProvider payme var confirmOrderModel = await _mediator.Send(new GetConfirmOrder { Cart = cart, Customer = _contextAccessor.WorkContext.CurrentCustomer, Language = _contextAccessor.WorkContext.WorkingLanguage, Currency = _contextAccessor.WorkContext.WorkingCurrency, TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType, Store = _contextAccessor.StoreContext.CurrentStore - }); + }, HttpContext.RequestAborted); return Json(new { update_section = new UpdateSectionJsonModel { name = "confirm-order", diff --git a/src/Web/Grand.Web/Controllers/OrderController.cs b/src/Web/Grand.Web/Controllers/OrderController.cs index d9fe9b8ff..5ecf9fe4a 100644 --- a/src/Web/Grand.Web/Controllers/OrderController.cs +++ b/src/Web/Grand.Web/Controllers/OrderController.cs @@ -91,11 +91,37 @@ public virtual async Task> Details(string orderI return Challenge(); var model = await _mediator.Send(new GetOrderDetails - { Order = order, Language = _contextAccessor.WorkContext.WorkingLanguage }); + { + Order = order, + Customer = _contextAccessor.WorkContext.CurrentCustomer, + Language = _contextAccessor.WorkContext.WorkingLanguage + }); return View(model); } + //My account / Order details page / Withdraw a request the store has not decided yet + [HttpPost] + public virtual async Task WithdrawApproval(string orderId) + { + var order = await _orderService.GetOrderById(orderId); + if (!await order.Access(_contextAccessor.WorkContext.CurrentCustomer, _groupService)) + return Challenge(); + + var withdrawn = await _mediator.Send(new WithdrawOrderApprovalCommand { + Customer = _contextAccessor.WorkContext.CurrentCustomer, + Store = _contextAccessor.StoreContext.CurrentStore, + OrderId = orderId + }); + + if (withdrawn) + Success(_translationService.GetResource("Order.Approval.Withdrawn")); + else + Error(_translationService.GetResource("Order.Approval.WithdrawRefused")); + + return RedirectToRoute("OrderDetails", new { orderId }); + } + //My account / Order details page / Cancel Unpaid Order [HttpGet] public virtual async Task CancelOrder(string orderId) diff --git a/src/Web/Grand.Web/Endpoints/EndpointProvider.cs b/src/Web/Grand.Web/Endpoints/EndpointProvider.cs index 1d2a23d76..312bbcba7 100644 --- a/src/Web/Grand.Web/Endpoints/EndpointProvider.cs +++ b/src/Web/Grand.Web/Endpoints/EndpointProvider.cs @@ -662,6 +662,10 @@ private void RegisterOrderRoute(IEndpointRouteBuilder endpointRouteBuilder, stri pattern + "orderdetails/cancel/{orderId}", new { controller = "Order", action = "CancelOrder" }); + endpointRouteBuilder.MapControllerRoute("WithdrawOrderApproval", + pattern + "orderdetails/withdrawapproval/{orderId}", + new { controller = "Order", action = "WithdrawApproval" }); + endpointRouteBuilder.MapControllerRoute("AddOrderNote", pattern + "orderdetails/ordernote/{orderId}", new { controller = "Order", action = "AddOrderNote" }); diff --git a/src/Web/Grand.Web/Features/Handlers/Checkout/GetConfirmOrderHandler.cs b/src/Web/Grand.Web/Features/Handlers/Checkout/GetConfirmOrderHandler.cs index 10b8ad51b..40163e0f7 100644 --- a/src/Web/Grand.Web/Features/Handlers/Checkout/GetConfirmOrderHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Checkout/GetConfirmOrderHandler.cs @@ -1,5 +1,4 @@ -using Grand.Business.Core.Interfaces.Catalog.Prices; -using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Checkout.Orders; using Grand.Business.Core.Interfaces.Checkout.Payments; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Queries.Checkout.Orders; @@ -21,9 +20,7 @@ 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 IOrderPlacementService _orderPlacementService; private readonly IRequestTranslationService _translationService; private readonly IMediator _mediator; private readonly OrderSettings _orderSettings; @@ -34,9 +31,7 @@ public GetConfirmOrderHandler( ICountryService countryService, IPaymentService paymentService, IMediator mediator, - IOrderApprovalService orderApprovalService, - ICurrencyService currencyService, - IPriceFormatter priceFormatter, + IOrderPlacementService orderPlacementService, IRequestTranslationService translationService, OrderSettings orderSettings, ShippingSettings shippingSettings) @@ -44,9 +39,7 @@ public GetConfirmOrderHandler( _countryService = countryService; _paymentService = paymentService; _mediator = mediator; - _orderApprovalService = orderApprovalService; - _currencyService = currencyService; - _priceFormatter = priceFormatter; + _orderPlacementService = orderPlacementService; _translationService = translationService; _orderSettings = orderSettings; _shippingSettings = shippingSettings; @@ -61,7 +54,7 @@ public async Task Handle(GetConfirmOrder request, Cancella PurchaseOrderNumberMaxLength = PurchaseOrderNumberRules.MaxLength }; await PrepareOrderReviewData(model, request); - await PrepareOrderApproval(model, request); + await PrepareOrderPlacement(model, request, cancellationToken); return model; } @@ -77,28 +70,30 @@ private PurchaseOrderNumberMode GetPurchaseOrderNumberMode(GetConfirmOrder reque } /// - /// An order over the buyer's limit is placed at once and waits for an approval; the buyer is told so + /// A blocked order cannot be confirmed, and one that needs an approval is placed at once and waits for it; + /// the buyer is told so, and why /// - private async Task PrepareOrderApproval(CheckoutConfirmModel model, GetConfirmOrder request) + private async Task PrepareOrderPlacement(CheckoutConfirmModel model, GetConfirmOrder request, + CancellationToken cancellationToken) { - var state = await _orderApprovalService.RequiresApproval(request.Cart, - new CalculationContext(request.Customer, request.Store, request.Currency, request.TaxDisplayType)); - if (!state.Required) - return; + var paymentMethod = request.Customer.GetUserFieldFromEntity( + SystemCustomerFieldNames.SelectedPaymentMethod, request.Store.Id); + var evaluation = await _orderPlacementService.Evaluate(request.Cart, + new CalculationContext(request.Customer, request.Store, request.Currency, request.TaxDisplayType), + request.Language, paymentMethod, cancellationToken); - model.OrderApprovalRequired = true; - var limit = await _currencyService.ConvertFromPrimaryStoreCurrency(state.Limit ?? 0, request.Currency); - var message = _translationService.GetResource("Checkout.OrderApproval.WillBeSent"); - try + if (evaluation.Decision == OrderPlacementDecision.Block) { - model.OrderApprovalMessage = string.Format(message, _priceFormatter.FormatPrice(limit, request.Currency)); + model.OrderPlacementBlocked = true; + model.OrderPlacementBlockedMessage = string.Join(" ", evaluation.Reasons); } - catch (FormatException) + else if (evaluation.Decision == OrderPlacementDecision.RequireApproval) { - //the text is editable by an operator: a broken placeholder must not break the confirm page - model.OrderApprovalMessage = message; + model.OrderApprovalRequired = true; + model.OrderApprovalMessage = string.Join(" ", + evaluation.Reasons.Prepend(_translationService.GetResource("Checkout.OrderApproval.WillBeSentGeneric"))); + model.OrderApprovalButtonText = _translationService.GetResource("Checkout.OrderApproval.SendButton"); } - model.OrderApprovalButtonText = _translationService.GetResource("Checkout.OrderApproval.SendButton"); } private async Task PrepareOrderReviewData(CheckoutConfirmModel model, GetConfirmOrder request) diff --git a/src/Web/Grand.Web/Features/Handlers/Checkout/GetPaymentMethodHandler.cs b/src/Web/Grand.Web/Features/Handlers/Checkout/GetPaymentMethodHandler.cs index 197604089..ebf8bc3ba 100644 --- a/src/Web/Grand.Web/Features/Handlers/Checkout/GetPaymentMethodHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Checkout/GetPaymentMethodHandler.cs @@ -31,6 +31,7 @@ public class GetPaymentMethodHandler : IRequestHandler _orderApprovalService.IsPaymentMethodAllowed(pm.SystemName)).ToList(); diff --git a/src/Web/Grand.Web/Features/Handlers/Orders/GetOrderApprovalHandler.cs b/src/Web/Grand.Web/Features/Handlers/Orders/GetOrderApprovalHandler.cs index 38673a870..ce0c49d3c 100644 --- a/src/Web/Grand.Web/Features/Handlers/Orders/GetOrderApprovalHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Orders/GetOrderApprovalHandler.cs @@ -52,6 +52,7 @@ public async Task Handle(GetOrderApproval request, Cancellat StatusId = (int)approval.Status, Status = _enumTranslationService.GetTranslationEnum(approval.Status), DecisionComment = approval.DecisionComment, + Reasons = approval.Reasons, // 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 diff --git a/src/Web/Grand.Web/Features/Handlers/Orders/GetOrderDetailsHandler.cs b/src/Web/Grand.Web/Features/Handlers/Orders/GetOrderDetailsHandler.cs index ce6e3df66..36f6b122c 100644 --- a/src/Web/Grand.Web/Features/Handlers/Orders/GetOrderDetailsHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Orders/GetOrderDetailsHandler.cs @@ -32,6 +32,7 @@ public class GetOrderDetailsHandler : IRequestHandler { public Order Order { get; set; } + public Customer Customer { get; set; } public Language Language { get; set; } } \ 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 ba8720134..2ac6c04e7 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.OrderPlacementBlockedMessage}} +
{{vmorder.OrderApprovalMessage}}
@@ -41,14 +44,14 @@
@Loc["Checkout.TermsOfService.PleaseAccept"]
-