From ece59c366e5c550114ba95005b705bd17f229f5f Mon Sep 17 00:00:00 2001 From: support Date: Sat, 31 Oct 2026 11:05:42 +0100 Subject: [PATCH 01/12] Add the order placement rule contract and the built-in limit provider --- .../Orders/OrderApprovalLimitRuleProvider.cs | 72 +++++ .../Services/Orders/OrderPlacementService.cs | 90 ++++++ .../Startup/StartupApplication.cs | 2 + .../Orders/IOrderPlacementRuleProvider.cs | 37 +++ .../Checkout/Orders/IOrderPlacementService.cs | 21 ++ .../Checkout/OrderPlacementContext.cs | 52 ++++ .../Checkout/OrderPlacementDecision.cs | 11 + .../Checkout/OrderPlacementEvaluation.cs | 8 + .../Checkout/OrderPlacementRuleResult.cs | 24 ++ .../OrderApprovalLimitRuleProviderTests.cs | 143 ++++++++++ .../Orders/OrderPlacementServiceTests.cs | 263 ++++++++++++++++++ 11 files changed, 723 insertions(+) create mode 100644 src/Business/Grand.Business.Checkout/Services/Orders/OrderApprovalLimitRuleProvider.cs create mode 100644 src/Business/Grand.Business.Checkout/Services/Orders/OrderPlacementService.cs create mode 100644 src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IOrderPlacementRuleProvider.cs create mode 100644 src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IOrderPlacementService.cs create mode 100644 src/Business/Grand.Business.Core/Utilities/Checkout/OrderPlacementContext.cs create mode 100644 src/Business/Grand.Business.Core/Utilities/Checkout/OrderPlacementDecision.cs create mode 100644 src/Business/Grand.Business.Core/Utilities/Checkout/OrderPlacementEvaluation.cs create mode 100644 src/Business/Grand.Business.Core/Utilities/Checkout/OrderPlacementRuleResult.cs create mode 100644 src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderApprovalLimitRuleProviderTests.cs create mode 100644 src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderPlacementServiceTests.cs 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..27cbf13a9 --- /dev/null +++ b/src/Business/Grand.Business.Checkout/Services/Orders/OrderApprovalLimitRuleProvider.cs @@ -0,0 +1,72 @@ +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) + { + 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/OrderPlacementService.cs b/src/Business/Grand.Business.Checkout/Services/Orders/OrderPlacementService.cs new file mode 100644 index 000000000..8c88dd62c --- /dev/null +++ b/src/Business/Grand.Business.Checkout/Services/Orders/OrderPlacementService.cs @@ -0,0 +1,90 @@ +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) + { + 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)); + + var decision = results.Count > 0 ? results.Max(x => x.Decision) : OrderPlacementDecision.Allow; + if (decision == OrderPlacementDecision.Allow) + return new OrderPlacementEvaluation(decision, []); + + return new OrderPlacementEvaluation(decision, results + .Where(x => x.Decision == decision) + .Select(x => Reason(x, language)) + .ToList()); + } + + /// + /// 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) + { + try + { + return await rule.Evaluate(context) + ?? throw new InvalidOperationException("The rule returned no result"); + } + 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/Interfaces/Checkout/Orders/IOrderPlacementRuleProvider.cs b/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IOrderPlacementRuleProvider.cs new file mode 100644 index 000000000..71465f6e8 --- /dev/null +++ b/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IOrderPlacementRuleProvider.cs @@ -0,0 +1,37 @@ +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. +/// +/// +/// Do not inject . Use for +/// the total, not . +/// +/// +/// +public interface IOrderPlacementRuleProvider : IProvider +{ + /// + /// Decides about the order being placed + /// + /// The order being placed + Task Evaluate(OrderPlacementContext context); +} 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..3ae0487c3 --- /dev/null +++ b/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IOrderPlacementService.cs @@ -0,0 +1,21 @@ +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 + Task Evaluate(IList cart, CalculationContext calculation, + Language language, string paymentMethodSystemName); +} 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..9ed513731 --- /dev/null +++ b/src/Business/Grand.Business.Core/Utilities/Checkout/OrderPlacementEvaluation.cs @@ -0,0 +1,8 @@ +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 reasons of the rules that produced this decision +public record OrderPlacementEvaluation(OrderPlacementDecision Decision, IReadOnlyList Reasons); 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..b743713bd --- /dev/null +++ b/src/Business/Grand.Business.Core/Utilities/Checkout/OrderPlacementRuleResult.cs @@ -0,0 +1,24 @@ +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 +public record OrderPlacementRuleResult(OrderPlacementDecision Decision, string Reason) +{ + 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 Block(string reason) + { + return new OrderPlacementRuleResult(OrderPlacementDecision.Block, reason); + } +} 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/OrderPlacementServiceTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderPlacementServiceTests.cs new file mode 100644 index 000000000..2bd23c1d3 --- /dev/null +++ b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderPlacementServiceTests.cs @@ -0,0 +1,263 @@ +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())).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()), Times.Once); + second.Verify(x => x.Evaluate(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())) + .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()), 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()), 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())) + .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())) + .Returns(async (OrderPlacementContext c) => + { + 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_NoProviderAsksForTotal_DoesNotCalculate() + { + Rule(OrderPlacementRuleResult.Allow()); + + await Evaluate(); + + _calculationService.Verify(x => x.GetShoppingCartTotal(It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } +} From 81724a6a3cc8b72b48e9b27de43113c7e1d1865a Mon Sep 17 00:00:00 2001 From: support Date: Sat, 31 Oct 2026 12:52:22 +0100 Subject: [PATCH 02/12] Evaluate order placement rules across the checkout --- .../Orders/PlaceOrderCommandHandler.cs | 23 +++- .../Services/Orders/OrderApprovalService.cs | 28 ---- .../Checkout/Orders/OrderPlacedEvent.cs | 5 + .../Checkout/Orders/IOrderApprovalService.cs | 10 -- .../Checkout/OrderApprovalCheckoutState.cs | 9 -- .../Utilities/Checkout/PlaceOrderContainer.cs | 7 +- .../PlaceOrderCommandHandlerApprovalTests.cs | 78 ++++++++++- ...ceOrderCommandHandlerPurchaseOrderTests.cs | 13 +- .../Orders/PlaceOrderCommandHandlerTests.cs | 2 +- .../Orders/OrderApprovalServiceTests.cs | 126 +----------------- .../OrderApprovalResourceKeysTests.cs | 5 +- .../CheckoutControllerConfirmOrderTests.cs | 3 +- .../CheckoutControllerOrderApprovalTests.cs | 32 ++++- .../CheckoutOrderApprovalHandlersTests.cs | 77 ++++++++--- .../Checkout/CheckoutConfirmModel.cs | 14 +- .../App_Data/Resources/DefaultLanguage.xml | Bin 1641900 -> 1642962 bytes .../App_Data/Resources/Upgrade/en_250.xml | 18 ++- .../Controllers/CheckoutController.cs | 13 +- .../Checkout/GetConfirmOrderHandler.cs | 48 +++---- .../Checkout/GetPaymentMethodHandler.cs | 17 ++- .../Checkout/Partials/ConfirmOrder.cshtml | 7 +- .../wwwroot/theme/script/public.checkout.js | 4 + 22 files changed, 277 insertions(+), 262 deletions(-) delete mode 100644 src/Business/Grand.Business.Core/Utilities/Checkout/OrderApprovalCheckoutState.cs 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..2fe4edfdd 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,9 +187,19 @@ 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; + //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); + 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(); if (details.ApprovalRequired && !_orderApprovalService.IsPaymentMethodAllowed(details.PaymentMethodSystemName)) { result.AddError(_translationService.GetResource("Checkout.OrderApproval.PaymentMethodNotAllowed", @@ -233,7 +246,9 @@ 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() + }, 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/Services/Orders/OrderApprovalService.cs b/src/Business/Grand.Business.Checkout/Services/Orders/OrderApprovalService.cs index f1fe7cdeb..52c2d1744 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; @@ -104,26 +96,6 @@ public virtual async Task Update(OrderApproval approval) await _mediator.EntityUpdated(approval); } - public virtual async Task RequiresApproval(IList cart, - CalculationContext context) - { - ArgumentNullException.ThrowIfNull(cart); - ArgumentNullException.ThrowIfNull(context); - - var customer = context.Customer; - var limit = customer.OrderApprovalLimit; - - // not a limited buyer: nothing about approvals applies (also when the sub-account feature is off) - if (_customerSettings.HideSubAccountsTab || string.IsNullOrEmpty(customer.OwnerId) || - OrderApprovalRules.CanApprove(customer) || !limit.HasValue) - return new OrderApprovalCheckoutState(false, limit, null); - - var total = (await _orderCalculationService.GetShoppingCartTotal(cart, context))?.Total; - - // the total is only known once shipping is chosen; until then the rule is not evaluated - return new OrderApprovalCheckoutState(total.HasValue && total.Value > limit.Value, limit, total); - } - public virtual bool IsPaymentMethodAllowed(string paymentMethodSystemName) { return !string.IsNullOrEmpty(paymentMethodSystemName) && 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..83079f7ea 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,9 @@ 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; } = []; } \ 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..cd8e748ab 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; @@ -56,15 +55,6 @@ Task> GetForCustomer(string customerId, string storeId /// Approval Task Update(OrderApproval approval); - /// - /// Works out whether the order being placed from the cart has to wait for an approval. - /// It does when the customer is a sub-account buyer with an order limit, the sub-account feature is on, - /// the order total is known and exceeds the limit. - /// - /// Shopping cart of the customer - /// Customer, store and currency the cart is calculated for - Task RequiresApproval(IList cart, CalculationContext context); - /// /// Gets a value indicating whether the payment method may be used for an order that needs an approval /// 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/PlaceOrderContainer.cs b/src/Business/Grand.Business.Core/Utilities/Checkout/PlaceOrderContainer.cs index ceeab4694..ba66d673c 100644 --- a/src/Business/Grand.Business.Core/Utilities/Checkout/PlaceOrderContainer.cs +++ b/src/Business/Grand.Business.Core/Utilities/Checkout/PlaceOrderContainer.cs @@ -41,9 +41,14 @@ 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(); public Address BillingAddress { get; set; } public Address ShippingAddress { get; set; } public ShippingStatus ShippingStatus { get; set; } 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..17a731938 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,73 @@ 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)) + .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_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_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 +158,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 +217,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 +233,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..665793a81 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,13 @@ 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())) + .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 +147,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 +161,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/Services/Orders/OrderApprovalServiceTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderApprovalServiceTests.cs index d882124f0..328b52cd6 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] diff --git a/src/Tests/Grand.Modules.Tests/Installer/OrderApprovalResourceKeysTests.cs b/src/Tests/Grand.Modules.Tests/Installer/OrderApprovalResourceKeysTests.cs index ac801308a..a9976953d 100644 --- a/src/Tests/Grand.Modules.Tests/Installer/OrderApprovalResourceKeysTests.cs +++ b/src/Tests/Grand.Modules.Tests/Installer/OrderApprovalResourceKeysTests.cs @@ -16,7 +16,7 @@ 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$" + @@ -81,7 +81,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.Web.Tests/Controllers/CheckoutControllerConfirmOrderTests.cs b/src/Tests/Grand.Web.Tests/Controllers/CheckoutControllerConfirmOrderTests.cs index bf1af8b4a..d024aeee4 100644 --- a/src/Tests/Grand.Web.Tests/Controllers/CheckoutControllerConfirmOrderTests.cs +++ b/src/Tests/Grand.Web.Tests/Controllers/CheckoutControllerConfirmOrderTests.cs @@ -67,7 +67,8 @@ 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 Mock().Object, new Mock().Object, new OrderSettings(), + new OidcLoginConfig(), new LoyaltyPointsSettings(), new PaymentSettings(), new ShippingSettings(), new AddressSettings()); } diff --git a/src/Tests/Grand.Web.Tests/Controllers/CheckoutControllerOrderApprovalTests.cs b/src/Tests/Grand.Web.Tests/Controllers/CheckoutControllerOrderApprovalTests.cs index 42387b12c..a3dea6799 100644 --- a/src/Tests/Grand.Web.Tests/Controllers/CheckoutControllerOrderApprovalTests.cs +++ b/src/Tests/Grand.Web.Tests/Controllers/CheckoutControllerOrderApprovalTests.cs @@ -32,9 +32,12 @@ 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 _paymentService; + private Mock _placement; [TestInitialize] public void Init() @@ -43,7 +46,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 +58,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); @@ -65,8 +71,9 @@ public void Init() .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")) + .ReturnsAsync(new OrderPlacementEvaluation(OrderPlacementDecision.RequireApproval, ["over the limit"])); _customerService = new Mock(); _paymentService = new Mock(); @@ -75,7 +82,8 @@ public void Init() 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 Mock().Object, _approval.Object, _placement.Object, new OrderSettings(), + new OidcLoginConfig(), new LoyaltyPointsSettings(), new PaymentSettings(), new ShippingSettings(), new AddressSettings()); } @@ -93,4 +101,18 @@ 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() + { + _placement.Setup(p => p.Evaluate(_cart, It.IsAny(), _language, "Payments.Card")) + .ReturnsAsync(new OrderPlacementEvaluation(OrderPlacementDecision.Block, ["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); + } } 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..af761b1c9 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())) + .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,31 @@ 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)) + .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 +190,51 @@ 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")) + .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); } private void SelectPaymentMethod(string systemName, PurchaseOrderNumberMode mode) 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/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index 7dd15fd1230ba07a5ae2be0ef1d97da8ca54720e..e9221459ba06800f12ebda3244816aaebec92176 100644 GIT binary patch delta 463 zcmZvYJ5K^Z5XTpRL%0KOIl04EyiN;4jFllK#zJGFjW%j0!ig9-k%x+&=Odd~Soolr z7z;14r6lnSSS!)kSXlT8pto@DG@&t@UuJjyGdurG{Vx6bkUn89*2Ub+!@P`RKGx0r zEWmgsupkSuFcTZ}!#FogibN0z=|Q51j3|$W8IvzT&Cqcum{fqLB8~Xtq(~|xM>IH| z4Ge<#PDj9c^KxL#^O4$t5E?3=nj|Trku*`UI!iKSla%qUlWnpGNy!z3=PJE&KBRFh zF5oGmxQq8(?X)@r&0AiEi7!roxkDO(#67F7M2#)7Pc1l7LFFq7OocV7M>Q~7QPn#7J)5-w`!(;s1_8O zK7n6SWIB&IufX&POp|N 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 b549b75b9..500d55a06 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,4 +762,19 @@ 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}. + diff --git a/src/Web/Grand.Web/Controllers/CheckoutController.cs b/src/Web/Grand.Web/Controllers/CheckoutController.cs index e5c585514..e2be38ce5 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; @@ -589,9 +591,13 @@ 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)) + //a blocked order goes no further; one that needs an approval may only use the payment methods the admin chose + var evaluation = await _orderPlacementService.Evaluate(cart, _contextAccessor.CalculationContext(), + _contextAccessor.WorkContext.WorkingLanguage, model.PaymentMethod); + if (evaluation.Decision == OrderPlacementDecision.Block) + throw new Exception(string.Join(" ", evaluation.Reasons)); + if (evaluation.Decision == OrderPlacementDecision.RequireApproval && + !_orderApprovalService.IsPaymentMethodAllowed(model.PaymentMethod)) throw new Exception(_translationService.GetResource("Checkout.OrderApproval.PaymentMethodNotAllowed")); var paymentMethodInst = _paymentService.LoadPaymentMethodBySystemName(model.PaymentMethod); @@ -846,6 +852,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; diff --git a/src/Web/Grand.Web/Features/Handlers/Checkout/GetConfirmOrderHandler.cs b/src/Web/Grand.Web/Features/Handlers/Checkout/GetConfirmOrderHandler.cs index 10b8ad51b..28930b6a8 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); return model; } @@ -77,28 +70,29 @@ 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) { - 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); - 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..f8602037f 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/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"]
-