From cf79b0ff9fb83d2eb36f5d334ed5e8601a14497e Mon Sep 17 00:00:00 2001 From: support Date: Thu, 15 Oct 2026 20:12:10 +0200 Subject: [PATCH 1/2] Resolve plugin dependencies through the constructor instead of HttpContext Payment, discount, shipping and widget plugins resolved services with HttpContext.RequestServices, so they threw outside a request (API module, scheduled tasks, background mediator). - Extract the cart subtotal into IShoppingCartSubTotalService; payment providers depend on it instead of IOrderCalculationService, which closed a cycle through IPaymentService. - ShoppingCartDiscountRule sends GetShoppingCartItemsSubTotalQuery instead of resolving IPricingService (cycle through the discount services), and reads the cart of request.Customer. - Percentage handling fee is no longer converted to the working currency twice. - Tests build the providers from DI without an HttpContext and check the real core services with the plugins for cycles. --- ...etShoppingCartItemsSubTotalQueryHandler.cs | 31 ++ .../Orders/OrderCalculationService.cs | 206 +------------ .../Orders/ShoppingCartSubTotalService.cs | 270 ++++++++++++++++++ .../Startup/StartupApplication.cs | 1 + .../Orders/IShoppingCartSubTotalService.cs | 21 ++ .../GetShoppingCartItemsSubTotalQuery.cs | 14 + .../Providers/ShoppingCartDiscountRule.cs | 35 +-- .../BrainTreePaymentProvider.cs | 35 +-- .../CashOnDeliveryPaymentProvider.cs | 45 ++- .../ByWeightShippingProvider.cs | 21 +- .../WidgetsGoogleAnalyticsViewComponent.cs | 33 ++- ...ppingCartItemsSubTotalQueryHandlerTests.cs | 38 +++ .../Orders/OrderCalculationServiceTests.cs | 11 +- .../Grand.Modules.Tests.csproj | 3 + .../ShoppingCartDiscountRuleTests.cs | 78 +++++ .../Payments/BrainTreePaymentProviderTests.cs | 74 +++++ .../CashOnDeliveryPaymentProviderTests.cs | 108 +++++++ .../Plugins/PluginDependencyCycleTests.cs | 114 ++++++++ 18 files changed, 833 insertions(+), 305 deletions(-) create mode 100644 src/Business/Grand.Business.Catalog/Queries/Handlers/GetShoppingCartItemsSubTotalQueryHandler.cs create mode 100644 src/Business/Grand.Business.Checkout/Services/Orders/ShoppingCartSubTotalService.cs create mode 100644 src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IShoppingCartSubTotalService.cs create mode 100644 src/Business/Grand.Business.Core/Queries/Catalog/GetShoppingCartItemsSubTotalQuery.cs create mode 100644 src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetShoppingCartItemsSubTotalQueryHandlerTests.cs create mode 100644 src/Tests/Grand.Modules.Tests/Plugins/DiscountRules/ShoppingCartDiscountRuleTests.cs create mode 100644 src/Tests/Grand.Modules.Tests/Plugins/Payments/BrainTreePaymentProviderTests.cs create mode 100644 src/Tests/Grand.Modules.Tests/Plugins/Payments/CashOnDeliveryPaymentProviderTests.cs create mode 100644 src/Tests/Grand.Modules.Tests/Plugins/PluginDependencyCycleTests.cs diff --git a/src/Business/Grand.Business.Catalog/Queries/Handlers/GetShoppingCartItemsSubTotalQueryHandler.cs b/src/Business/Grand.Business.Catalog/Queries/Handlers/GetShoppingCartItemsSubTotalQueryHandler.cs new file mode 100644 index 0000000000..905b8bcbce --- /dev/null +++ b/src/Business/Grand.Business.Catalog/Queries/Handlers/GetShoppingCartItemsSubTotalQueryHandler.cs @@ -0,0 +1,31 @@ +using Grand.Business.Core.Interfaces.Catalog.Prices; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Queries.Catalog; +using Grand.Mediator; + +namespace Grand.Business.Catalog.Queries.Handlers; + +public class GetShoppingCartItemsSubTotalQueryHandler : IRequestHandler +{ + private readonly IPricingService _pricingService; + private readonly IProductService _productService; + + public GetShoppingCartItemsSubTotalQueryHandler(IPricingService pricingService, IProductService productService) + { + _pricingService = pricingService; + _productService = productService; + } + + public async Task Handle(GetShoppingCartItemsSubTotalQuery request, CancellationToken cancellationToken) + { + double subTotal = 0; + foreach (var item in request.Items) + { + var product = await _productService.GetProductById(item.ProductId); + if (product != null) + subTotal += (await _pricingService.GetSubTotal(item, product, false)).subTotal; + } + + return subTotal; + } +} diff --git a/src/Business/Grand.Business.Checkout/Services/Orders/OrderCalculationService.cs b/src/Business/Grand.Business.Checkout/Services/Orders/OrderCalculationService.cs index f489bc16ee..46a2253159 100644 --- a/src/Business/Grand.Business.Checkout/Services/Orders/OrderCalculationService.cs +++ b/src/Business/Grand.Business.Checkout/Services/Orders/OrderCalculationService.cs @@ -1,9 +1,6 @@ using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Catalog.Discounts; -using Grand.Business.Core.Interfaces.Catalog.Prices; -using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Catalog.Tax; -using Grand.Business.Core.Interfaces.Checkout.CheckoutAttributes; using Grand.Business.Core.Interfaces.Checkout.GiftVouchers; using Grand.Business.Core.Interfaces.Checkout.Orders; using Grand.Business.Core.Interfaces.Checkout.Payments; @@ -36,15 +33,13 @@ public class OrderCalculationService : IOrderCalculationService /// Ctor /// /// Work context - /// Price calculation service + /// Shopping cart subtotal service /// Tax service /// Shipping service /// Payment service - /// Checkout attribute parser /// Discount service /// Gift voucher service /// Loyalty points service - /// Product service /// Currency service /// Group /// Discount validation service @@ -54,15 +49,13 @@ public class OrderCalculationService : IOrderCalculationService /// Shopping cart settings /// Catalog settings public OrderCalculationService(IContextAccessor contextAccessor, - IPricingService priceCalculationService, + IShoppingCartSubTotalService shoppingCartSubTotalService, ITaxService taxService, IShippingService shippingService, IPaymentService paymentService, - ICheckoutAttributeParser checkoutAttributeParser, IDiscountService discountService, IGiftVoucherService giftVoucherService, ILoyaltyPointsService loyaltyPointsService, - IProductService productService, ICurrencyService currencyService, IGroupService groupService, IDiscountValidationService discountValidationService, @@ -73,15 +66,13 @@ public OrderCalculationService(IContextAccessor contextAccessor, CatalogSettings catalogSettings) { _contextAccessor = contextAccessor; - _pricingService = priceCalculationService; + _shoppingCartSubTotalService = shoppingCartSubTotalService; _taxService = taxService; _shippingService = shippingService; _paymentService = paymentService; - _checkoutAttributeParser = checkoutAttributeParser; _discountService = discountService; _giftVoucherService = giftVoucherService; _loyaltyPointsService = loyaltyPointsService; - _productService = productService; _currencyService = currencyService; _groupService = groupService; _discountValidationService = discountValidationService; @@ -97,15 +88,13 @@ public OrderCalculationService(IContextAccessor contextAccessor, #region Fields private readonly IContextAccessor _contextAccessor; - private readonly IPricingService _pricingService; + private readonly IShoppingCartSubTotalService _shoppingCartSubTotalService; private readonly ITaxService _taxService; private readonly IShippingService _shippingService; private readonly IPaymentService _paymentService; - private readonly ICheckoutAttributeParser _checkoutAttributeParser; private readonly IDiscountService _discountService; private readonly IGiftVoucherService _giftVoucherService; private readonly ILoyaltyPointsService _loyaltyPointsService; - private readonly IProductService _productService; private readonly ICurrencyService _currencyService; private readonly IGroupService _groupService; private readonly IDiscountValidationService _discountValidationService; @@ -119,52 +108,6 @@ public OrderCalculationService(IContextAccessor contextAccessor, #region Utilities - /// - /// Gets an order discount (applied to order subtotal) - /// - /// Customer - /// Store - /// Currency - /// Order subtotal - /// Order discount - protected virtual async Task<(double ordersubtotaldiscount, List appliedDiscounts)> - GetOrderSubtotalDiscount(Customer customer, Store store, Currency currency, double orderSubTotal) - { - var appliedDiscounts = new List(); - double discountAmount = 0; - if (_catalogSettings.IgnoreDiscounts) - return (discountAmount, appliedDiscounts); - - var allDiscounts = await _discountService.GetActiveDiscountsByContext(DiscountType.AssignedToOrderSubTotal, - _contextAccessor.StoreContext.CurrentStore.Id, currency.CurrencyCode); - var allowedDiscounts = new List(); - if (allDiscounts != null) - foreach (var discount in allDiscounts) - { - var validDiscount = - await _discountValidationService.ValidateDiscount(discount, customer, store, currency); - if (validDiscount.IsValid && - discount.DiscountTypeId == DiscountType.AssignedToOrderSubTotal && - allowedDiscounts.All(x => x.DiscountId != discount.Id)) - allowedDiscounts.Add(new ApplyDiscount { - DiscountId = discount.Id, - IsCumulative = discount.IsCumulative, - CouponCode = validDiscount.CouponCode, - MaximumDiscountedQuantity = discount.MaximumDiscountedQuantity - }); - } - - var preferredDiscounts = await _discountService.GetPreferredDiscount(allowedDiscounts, customer, - _contextAccessor.WorkContext.WorkingCurrency, orderSubTotal); - appliedDiscounts = preferredDiscounts.appliedDiscount; - discountAmount = preferredDiscounts.discountAmount; - - if (discountAmount < 0) - discountAmount = 0; - - return (discountAmount, appliedDiscounts); - } - /// /// Gets a shipping discount /// @@ -297,148 +240,11 @@ private async Task> GetActiveGiftVouchers(Customer customer, /// /// Cart /// A value indicating whether calculated price should include tax - public virtual async Task<(double discountAmount, List appliedDiscounts, + public virtual Task<(double discountAmount, List appliedDiscounts, double subTotalWithoutDiscount, double subTotalWithDiscount, SortedDictionary taxRates)> GetShoppingCartSubTotal(IList cart, bool includingTax) { - double discountAmount = 0; - var appliedDiscounts = new List(); - double subTotalWithoutDiscount = 0; - double subTotalWithDiscount = 0; - var taxRates = new SortedDictionary(); - - if (!cart.Any()) - return (discountAmount, appliedDiscounts, subTotalWithoutDiscount, subTotalWithDiscount, taxRates); - - //get the customer - var customer = _contextAccessor.WorkContext.CurrentCustomer; - var store = _contextAccessor.StoreContext.CurrentStore; - - //sub totals - double subTotalExclTaxWithoutDiscount = 0; - double subTotalInclTaxWithoutDiscount = 0; - foreach (var shoppingCartItem in cart) - { - var product = await _productService.GetProductById(shoppingCartItem.ProductId); - if (product == null) - continue; - - var subtotal = await _pricingService.GetSubTotal(shoppingCartItem, product); - var sciSubTotal = subtotal.subTotal; - - var pricesExcl = await _taxService.GetProductPrice(product, sciSubTotal, false, customer, store); - var sciExclTax = pricesExcl.productprice; - - var (sciInclTax, taxRate) = await _taxService.GetProductPrice(product, sciSubTotal, true, customer, store); - - subTotalExclTaxWithoutDiscount += sciExclTax; - subTotalInclTaxWithoutDiscount += sciInclTax; - - //tax rates - var sciTax = sciInclTax - sciExclTax; - if (!(taxRate > 0) || !(sciTax > 0)) continue; - if (!taxRates.ContainsKey(taxRate)) - taxRates.Add(taxRate, sciTax); - else - taxRates[taxRate] += sciTax; - } - - //checkout attributes - if (customer != null) - { - var checkoutAttributes = customer.GetUserFieldFromEntity>(SystemCustomerFieldNames.CheckoutAttributes, store.Id); - var attributeValues = await _checkoutAttributeParser.ParseCheckoutAttributeValue(checkoutAttributes); - foreach (var attributeValue in attributeValues) - { - var checkoutAttributePriceExclTax = await _taxService.GetCheckoutAttributePrice(attributeValue.ca, attributeValue.cav, false, customer, store); - - var caExclTax = await _currencyService.ConvertFromPrimaryStoreCurrency(checkoutAttributePriceExclTax.checkoutPrice, _contextAccessor.WorkContext.WorkingCurrency); - var (checkoutPrice, taxRate) = await _taxService.GetCheckoutAttributePrice(attributeValue.ca, attributeValue.cav, true, customer, store); - var caInclTax = await _currencyService.ConvertFromPrimaryStoreCurrency(checkoutPrice, _contextAccessor.WorkContext.WorkingCurrency); - - subTotalExclTaxWithoutDiscount += caExclTax; - subTotalInclTaxWithoutDiscount += caInclTax; - - //tax rates - var caTax = caInclTax - caExclTax; - if (!(taxRate > 0) || !(caTax > 0)) continue; - if (!taxRates.ContainsKey(taxRate)) - taxRates.Add(taxRate, caTax); - else - taxRates[taxRate] += caTax; - } - } - - //subtotal without discount - subTotalWithoutDiscount = includingTax ? subTotalInclTaxWithoutDiscount : subTotalExclTaxWithoutDiscount; - - if (subTotalWithoutDiscount < 0) - subTotalWithoutDiscount = 0; - - if (_shoppingCartSettings.RoundPrices) - subTotalWithoutDiscount = RoundingHelper.RoundPrice(subTotalWithoutDiscount, _contextAccessor.WorkContext.WorkingCurrency); - - //We calculate discount amount on order subtotal excl tax (discount first) - //calculate discount amount ('Applied to order subtotal' discount) - var orderSubtotalDiscount = await GetOrderSubtotalDiscount(customer, _contextAccessor.StoreContext.CurrentStore, - _contextAccessor.WorkContext.WorkingCurrency, subTotalExclTaxWithoutDiscount); - var discountAmountExclTax = orderSubtotalDiscount.ordersubtotaldiscount; - appliedDiscounts = orderSubtotalDiscount.appliedDiscounts; - - if (subTotalExclTaxWithoutDiscount < discountAmountExclTax) - discountAmountExclTax = subTotalExclTaxWithoutDiscount; - var discountAmountInclTax = discountAmountExclTax; - //subtotal with discount (excl tax) - var subTotalExclTaxWithDiscount = subTotalExclTaxWithoutDiscount - discountAmountExclTax; - var subTotalInclTaxWithDiscount = subTotalExclTaxWithDiscount; - - //add tax for shopping items & checkout attributes - var tempTaxRates = new Dictionary(taxRates); - foreach (var kvp in tempTaxRates) - { - var taxRate = kvp.Key; - var taxValue = kvp.Value; - - if (taxValue == 0) continue; - //discount the tax amount that applies to subtotal items - if (subTotalExclTaxWithoutDiscount > 0) - { - var discountTax = taxRates[taxRate] * (discountAmountExclTax / subTotalExclTaxWithoutDiscount); - discountAmountInclTax += discountTax; - taxValue = taxRates[taxRate] - discountTax; - if (_shoppingCartSettings.RoundPrices) - taxValue = RoundingHelper.RoundPrice(taxValue, _contextAccessor.WorkContext.WorkingCurrency); - taxRates[taxRate] = taxValue; - } - - //subtotal with discount (incl tax) - subTotalInclTaxWithDiscount += taxValue; - } - - if (_shoppingCartSettings.RoundPrices) - { - discountAmountInclTax = RoundingHelper.RoundPrice(discountAmountInclTax, _contextAccessor.WorkContext.WorkingCurrency); - discountAmountExclTax = RoundingHelper.RoundPrice(discountAmountExclTax, _contextAccessor.WorkContext.WorkingCurrency); - } - - if (includingTax) - { - subTotalWithDiscount = subTotalInclTaxWithDiscount; - discountAmount = discountAmountInclTax; - } - else - { - subTotalWithDiscount = subTotalExclTaxWithDiscount; - discountAmount = discountAmountExclTax; - } - - if (subTotalWithDiscount < 0) - subTotalWithDiscount = 0; - - if (_shoppingCartSettings.RoundPrices) - subTotalWithDiscount = RoundingHelper.RoundPrice(subTotalWithDiscount, _contextAccessor.WorkContext.WorkingCurrency); - - return (discountAmount, appliedDiscounts, subTotalWithoutDiscount, subTotalWithDiscount, taxRates); + return _shoppingCartSubTotalService.GetShoppingCartSubTotal(cart, includingTax); } diff --git a/src/Business/Grand.Business.Checkout/Services/Orders/ShoppingCartSubTotalService.cs b/src/Business/Grand.Business.Checkout/Services/Orders/ShoppingCartSubTotalService.cs new file mode 100644 index 0000000000..9ad776e112 --- /dev/null +++ b/src/Business/Grand.Business.Checkout/Services/Orders/ShoppingCartSubTotalService.cs @@ -0,0 +1,270 @@ +using Grand.Business.Core.Extensions; +using Grand.Business.Core.Interfaces.Catalog.Discounts; +using Grand.Business.Core.Interfaces.Catalog.Prices; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Catalog.Tax; +using Grand.Business.Core.Interfaces.Checkout.CheckoutAttributes; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Utilities.Catalog; +using Grand.Domain.Catalog; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Domain.Directory; +using Grand.Domain.Discounts; +using Grand.Domain.Orders; +using Grand.Domain.Stores; +using Grand.Infrastructure; + +namespace Grand.Business.Checkout.Services.Orders; + +/// +/// Shopping cart subtotal calculation +/// +public class ShoppingCartSubTotalService : IShoppingCartSubTotalService +{ + #region Ctor + + public ShoppingCartSubTotalService(IContextAccessor contextAccessor, + IPricingService pricingService, + ITaxService taxService, + ICheckoutAttributeParser checkoutAttributeParser, + IDiscountService discountService, + IDiscountValidationService discountValidationService, + IProductService productService, + ICurrencyService currencyService, + ShoppingCartSettings shoppingCartSettings, + CatalogSettings catalogSettings) + { + _contextAccessor = contextAccessor; + _pricingService = pricingService; + _taxService = taxService; + _checkoutAttributeParser = checkoutAttributeParser; + _discountService = discountService; + _discountValidationService = discountValidationService; + _productService = productService; + _currencyService = currencyService; + _shoppingCartSettings = shoppingCartSettings; + _catalogSettings = catalogSettings; + } + + #endregion + + #region Fields + + private readonly IContextAccessor _contextAccessor; + private readonly IPricingService _pricingService; + private readonly ITaxService _taxService; + private readonly ICheckoutAttributeParser _checkoutAttributeParser; + private readonly IDiscountService _discountService; + private readonly IDiscountValidationService _discountValidationService; + private readonly IProductService _productService; + private readonly ICurrencyService _currencyService; + private readonly ShoppingCartSettings _shoppingCartSettings; + private readonly CatalogSettings _catalogSettings; + + #endregion + + #region Utilities + + /// + /// Gets an order discount (applied to order subtotal) + /// + /// Customer + /// Store + /// Currency + /// Order subtotal + /// Order discount + protected virtual async Task<(double ordersubtotaldiscount, List appliedDiscounts)> + GetOrderSubtotalDiscount(Customer customer, Store store, Currency currency, double orderSubTotal) + { + var appliedDiscounts = new List(); + double discountAmount = 0; + if (_catalogSettings.IgnoreDiscounts) + return (discountAmount, appliedDiscounts); + + var allDiscounts = await _discountService.GetActiveDiscountsByContext(DiscountType.AssignedToOrderSubTotal, + _contextAccessor.StoreContext.CurrentStore.Id, currency.CurrencyCode); + var allowedDiscounts = new List(); + if (allDiscounts != null) + foreach (var discount in allDiscounts) + { + var validDiscount = + await _discountValidationService.ValidateDiscount(discount, customer, store, currency); + if (validDiscount.IsValid && + discount.DiscountTypeId == DiscountType.AssignedToOrderSubTotal && + allowedDiscounts.All(x => x.DiscountId != discount.Id)) + allowedDiscounts.Add(new ApplyDiscount { + DiscountId = discount.Id, + IsCumulative = discount.IsCumulative, + CouponCode = validDiscount.CouponCode, + MaximumDiscountedQuantity = discount.MaximumDiscountedQuantity + }); + } + + var preferredDiscounts = await _discountService.GetPreferredDiscount(allowedDiscounts, customer, + _contextAccessor.WorkContext.WorkingCurrency, orderSubTotal); + appliedDiscounts = preferredDiscounts.appliedDiscount; + discountAmount = preferredDiscounts.discountAmount; + + if (discountAmount < 0) + discountAmount = 0; + + return (discountAmount, appliedDiscounts); + } + + #endregion + + #region Methods + + /// + /// Gets shopping cart subtotal + /// + /// Cart + /// A value indicating whether calculated price should include tax + public virtual async Task<(double discountAmount, List appliedDiscounts, + double subTotalWithoutDiscount, double subTotalWithDiscount, SortedDictionary taxRates)> + GetShoppingCartSubTotal(IList cart, bool includingTax) + { + double discountAmount = 0; + var appliedDiscounts = new List(); + double subTotalWithoutDiscount = 0; + double subTotalWithDiscount = 0; + var taxRates = new SortedDictionary(); + + if (!cart.Any()) + return (discountAmount, appliedDiscounts, subTotalWithoutDiscount, subTotalWithDiscount, taxRates); + + //get the customer + var customer = _contextAccessor.WorkContext.CurrentCustomer; + var store = _contextAccessor.StoreContext.CurrentStore; + + //sub totals + double subTotalExclTaxWithoutDiscount = 0; + double subTotalInclTaxWithoutDiscount = 0; + foreach (var shoppingCartItem in cart) + { + var product = await _productService.GetProductById(shoppingCartItem.ProductId); + if (product == null) + continue; + + var subtotal = await _pricingService.GetSubTotal(shoppingCartItem, product); + var sciSubTotal = subtotal.subTotal; + + var pricesExcl = await _taxService.GetProductPrice(product, sciSubTotal, false, customer, store); + var sciExclTax = pricesExcl.productprice; + + var (sciInclTax, taxRate) = await _taxService.GetProductPrice(product, sciSubTotal, true, customer, store); + + subTotalExclTaxWithoutDiscount += sciExclTax; + subTotalInclTaxWithoutDiscount += sciInclTax; + + //tax rates + var sciTax = sciInclTax - sciExclTax; + if (!(taxRate > 0) || !(sciTax > 0)) continue; + if (!taxRates.ContainsKey(taxRate)) + taxRates.Add(taxRate, sciTax); + else + taxRates[taxRate] += sciTax; + } + + //checkout attributes + if (customer != null) + { + var checkoutAttributes = customer.GetUserFieldFromEntity>(SystemCustomerFieldNames.CheckoutAttributes, store.Id); + var attributeValues = await _checkoutAttributeParser.ParseCheckoutAttributeValue(checkoutAttributes); + foreach (var attributeValue in attributeValues) + { + var checkoutAttributePriceExclTax = await _taxService.GetCheckoutAttributePrice(attributeValue.ca, attributeValue.cav, false, customer, store); + + var caExclTax = await _currencyService.ConvertFromPrimaryStoreCurrency(checkoutAttributePriceExclTax.checkoutPrice, _contextAccessor.WorkContext.WorkingCurrency); + var (checkoutPrice, taxRate) = await _taxService.GetCheckoutAttributePrice(attributeValue.ca, attributeValue.cav, true, customer, store); + var caInclTax = await _currencyService.ConvertFromPrimaryStoreCurrency(checkoutPrice, _contextAccessor.WorkContext.WorkingCurrency); + + subTotalExclTaxWithoutDiscount += caExclTax; + subTotalInclTaxWithoutDiscount += caInclTax; + + //tax rates + var caTax = caInclTax - caExclTax; + if (!(taxRate > 0) || !(caTax > 0)) continue; + if (!taxRates.ContainsKey(taxRate)) + taxRates.Add(taxRate, caTax); + else + taxRates[taxRate] += caTax; + } + } + + //subtotal without discount + subTotalWithoutDiscount = includingTax ? subTotalInclTaxWithoutDiscount : subTotalExclTaxWithoutDiscount; + + if (subTotalWithoutDiscount < 0) + subTotalWithoutDiscount = 0; + + if (_shoppingCartSettings.RoundPrices) + subTotalWithoutDiscount = RoundingHelper.RoundPrice(subTotalWithoutDiscount, _contextAccessor.WorkContext.WorkingCurrency); + + //We calculate discount amount on order subtotal excl tax (discount first) + //calculate discount amount ('Applied to order subtotal' discount) + var orderSubtotalDiscount = await GetOrderSubtotalDiscount(customer, _contextAccessor.StoreContext.CurrentStore, + _contextAccessor.WorkContext.WorkingCurrency, subTotalExclTaxWithoutDiscount); + var discountAmountExclTax = orderSubtotalDiscount.ordersubtotaldiscount; + appliedDiscounts = orderSubtotalDiscount.appliedDiscounts; + + if (subTotalExclTaxWithoutDiscount < discountAmountExclTax) + discountAmountExclTax = subTotalExclTaxWithoutDiscount; + var discountAmountInclTax = discountAmountExclTax; + //subtotal with discount (excl tax) + var subTotalExclTaxWithDiscount = subTotalExclTaxWithoutDiscount - discountAmountExclTax; + var subTotalInclTaxWithDiscount = subTotalExclTaxWithDiscount; + + //add tax for shopping items & checkout attributes + var tempTaxRates = new Dictionary(taxRates); + foreach (var kvp in tempTaxRates) + { + var taxRate = kvp.Key; + var taxValue = kvp.Value; + + if (taxValue == 0) continue; + //discount the tax amount that applies to subtotal items + if (subTotalExclTaxWithoutDiscount > 0) + { + var discountTax = taxRates[taxRate] * (discountAmountExclTax / subTotalExclTaxWithoutDiscount); + discountAmountInclTax += discountTax; + taxValue = taxRates[taxRate] - discountTax; + if (_shoppingCartSettings.RoundPrices) + taxValue = RoundingHelper.RoundPrice(taxValue, _contextAccessor.WorkContext.WorkingCurrency); + taxRates[taxRate] = taxValue; + } + + //subtotal with discount (incl tax) + subTotalInclTaxWithDiscount += taxValue; + } + + if (_shoppingCartSettings.RoundPrices) + { + discountAmountInclTax = RoundingHelper.RoundPrice(discountAmountInclTax, _contextAccessor.WorkContext.WorkingCurrency); + discountAmountExclTax = RoundingHelper.RoundPrice(discountAmountExclTax, _contextAccessor.WorkContext.WorkingCurrency); + } + + if (includingTax) + { + subTotalWithDiscount = subTotalInclTaxWithDiscount; + discountAmount = discountAmountInclTax; + } + else + { + subTotalWithDiscount = subTotalExclTaxWithDiscount; + discountAmount = discountAmountExclTax; + } + + if (subTotalWithDiscount < 0) + subTotalWithDiscount = 0; + + if (_shoppingCartSettings.RoundPrices) + subTotalWithDiscount = RoundingHelper.RoundPrice(subTotalWithDiscount, _contextAccessor.WorkContext.WorkingCurrency); + + return (discountAmount, appliedDiscounts, subTotalWithoutDiscount, subTotalWithDiscount, taxRates); + } + + #endregion +} diff --git a/src/Business/Grand.Business.Checkout/Startup/StartupApplication.cs b/src/Business/Grand.Business.Checkout/Startup/StartupApplication.cs index 10d1f067aa..d56dc579c9 100644 --- a/src/Business/Grand.Business.Checkout/Startup/StartupApplication.cs +++ b/src/Business/Grand.Business.Checkout/Startup/StartupApplication.cs @@ -45,6 +45,7 @@ private static void RegisterOrdersService(IServiceCollection serviceCollection) serviceCollection.AddScoped(); serviceCollection.AddScoped(); serviceCollection.AddScoped(); + serviceCollection.AddScoped(); serviceCollection.AddScoped(); serviceCollection.AddScoped(); serviceCollection.AddScoped(); diff --git a/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IShoppingCartSubTotalService.cs b/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IShoppingCartSubTotalService.cs new file mode 100644 index 0000000000..35cd4534b7 --- /dev/null +++ b/src/Business/Grand.Business.Core/Interfaces/Checkout/Orders/IShoppingCartSubTotalService.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Utilities.Catalog; +using Grand.Domain.Orders; + +namespace Grand.Business.Core.Interfaces.Checkout.Orders; + +/// +/// Calculates the shopping cart subtotal. +/// Kept apart from so a payment provider can depend on it: +/// the order calculation depends on the payment service, which depends on every payment provider. +/// +public interface IShoppingCartSubTotalService +{ + /// + /// Gets shopping cart subtotal + /// + /// Cart + /// A value indicating whether calculated price should include tax + Task<(double discountAmount, List appliedDiscounts, double subTotalWithoutDiscount, double + subTotalWithDiscount, SortedDictionary taxRates)> + GetShoppingCartSubTotal(IList cart, bool includingTax); +} diff --git a/src/Business/Grand.Business.Core/Queries/Catalog/GetShoppingCartItemsSubTotalQuery.cs b/src/Business/Grand.Business.Core/Queries/Catalog/GetShoppingCartItemsSubTotalQuery.cs new file mode 100644 index 0000000000..b9528e6c1f --- /dev/null +++ b/src/Business/Grand.Business.Core/Queries/Catalog/GetShoppingCartItemsSubTotalQuery.cs @@ -0,0 +1,14 @@ +using Grand.Domain.Orders; +using Grand.Mediator; + +namespace Grand.Business.Core.Queries.Catalog; + +/// +/// Sum of the shopping cart items' subtotals, without discounts. +/// A discount rule sends this instead of injecting IPricingService: +/// the pricing service depends on discounts, so a rule that depends on it closes a DI cycle. +/// +public class GetShoppingCartItemsSubTotalQuery : IRequest +{ + public IList Items { get; set; } = new List(); +} diff --git a/src/Plugins/DiscountRules.Standard/Providers/ShoppingCartDiscountRule.cs b/src/Plugins/DiscountRules.Standard/Providers/ShoppingCartDiscountRule.cs index 9b584ee8aa..8b799c41be 100644 --- a/src/Plugins/DiscountRules.Standard/Providers/ShoppingCartDiscountRule.cs +++ b/src/Plugins/DiscountRules.Standard/Providers/ShoppingCartDiscountRule.cs @@ -1,30 +1,21 @@ 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.Queries.Catalog; using Grand.Business.Core.Utilities.Catalog; using Grand.Domain.Orders; -using Grand.Infrastructure; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; +using Grand.Mediator; namespace DiscountRules.Standard.Providers; public class ShoppingCartDiscountRule : IDiscountRule { - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly IProductService _productService; + private readonly IMediator _mediator; private readonly ShoppingCartSettings _shoppingCartSettings; - private readonly IContextAccessor _contextAccessor; public ShoppingCartDiscountRule( - IContextAccessor contextAccessor, - IProductService productService, - IHttpContextAccessor httpContextAccessor, + IMediator mediator, ShoppingCartSettings shoppingCartSettings) { - _contextAccessor = contextAccessor; - _productService = productService; - _httpContextAccessor = httpContextAccessor; + _mediator = mediator; _shoppingCartSettings = shoppingCartSettings; } @@ -50,7 +41,9 @@ public async Task CheckRequirement(DiscountRuleVal return result; } - var cart = _contextAccessor.WorkContext.CurrentCustomer.ShoppingCartItems + if (request.Customer == null) return result; + + var cart = request.Customer.ShoppingCartItems .Where(sci => sci.ShoppingCartTypeId == ShoppingCartType.ShoppingCart) .LimitPerStore(_shoppingCartSettings.SharedCartBetweenStores, request.Store.Id) .ToList(); @@ -61,17 +54,7 @@ public async Task CheckRequirement(DiscountRuleVal return result; } - double spentAmount = 0; - - var priceCalculationService = - _httpContextAccessor.HttpContext!.RequestServices.GetRequiredService(); - - foreach (var ca in cart) - { - var product = await _productService.GetProductById(ca.ProductId); - if (product != null) - spentAmount += (await priceCalculationService.GetSubTotal(ca, product, false)).subTotal; - } + var spentAmount = await _mediator.Send(new GetShoppingCartItemsSubTotalQuery { Items = cart }); result.IsValid = spentAmount > spentAmountRequirement; return result; diff --git a/src/Plugins/Payments.BrainTree/BrainTreePaymentProvider.cs b/src/Plugins/Payments.BrainTree/BrainTreePaymentProvider.cs index aec592ca0c..fc6eeddbe7 100644 --- a/src/Plugins/Payments.BrainTree/BrainTreePaymentProvider.cs +++ b/src/Plugins/Payments.BrainTree/BrainTreePaymentProvider.cs @@ -10,7 +10,6 @@ using Grand.Domain.Payments; using Grand.Infrastructure; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Primitives; using Payments.BrainTree.Models; using Payments.BrainTree.Validators; @@ -22,19 +21,28 @@ namespace Payments.BrainTree; public class BrainTreePaymentProvider : IPaymentProvider { private readonly BrainTreePaymentSettings _brainTreePaymentSettings; + private readonly IContextAccessor _contextAccessor; + private readonly ICurrencyService _currencyService; private readonly ICustomerService _customerService; private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IShoppingCartSubTotalService _shoppingCartSubTotalService; private readonly ITranslationService _translationService; public BrainTreePaymentProvider( ITranslationService translationService, ICustomerService customerService, IHttpContextAccessor httpContextAccessor, + IShoppingCartSubTotalService shoppingCartSubTotalService, + ICurrencyService currencyService, + IContextAccessor contextAccessor, BrainTreePaymentSettings brainTreePaymentSettings) { _translationService = translationService; _customerService = customerService; _httpContextAccessor = httpContextAccessor; + _shoppingCartSubTotalService = shoppingCartSubTotalService; + _currencyService = currencyService; + _contextAccessor = contextAccessor; _brainTreePaymentSettings = brainTreePaymentSettings; } @@ -224,29 +232,16 @@ public async Task GetAdditionalHandlingFee(IList cart) if (_brainTreePaymentSettings.AdditionalFee <= 0) return _brainTreePaymentSettings.AdditionalFee; - double result; if (_brainTreePaymentSettings.AdditionalFeePercentage) { - //percentage - var orderTotalCalculationService = _httpContextAccessor.HttpContext!.RequestServices - .GetRequiredService(); - var subtotal = await orderTotalCalculationService.GetShoppingCartSubTotal(cart, true); - result = (float)subtotal.subTotalWithDiscount * - (float)_brainTreePaymentSettings.AdditionalFee / 100f; - } - else - { - //fixed value - result = _brainTreePaymentSettings.AdditionalFee; + //percentage of the subtotal, which is already in the working currency + var subtotal = await _shoppingCartSubTotalService.GetShoppingCartSubTotal(cart, true); + return (float)subtotal.subTotalWithDiscount * (float)_brainTreePaymentSettings.AdditionalFee / 100f; } - if (!(result > 0)) return await Task.FromResult(result); - var currencyService = _httpContextAccessor.HttpContext!.RequestServices.GetRequiredService(); - var workContext = _httpContextAccessor.HttpContext!.RequestServices.GetRequiredService().WorkContext; - result = await currencyService.ConvertFromPrimaryStoreCurrency(result, workContext.WorkingCurrency); - - //return result; - return await Task.FromResult(result); + //fixed value, kept in the primary store currency + return await _currencyService.ConvertFromPrimaryStoreCurrency(_brainTreePaymentSettings.AdditionalFee, + _contextAccessor.WorkContext.WorkingCurrency); } /// diff --git a/src/Plugins/Payments.CashOnDelivery/CashOnDeliveryPaymentProvider.cs b/src/Plugins/Payments.CashOnDelivery/CashOnDeliveryPaymentProvider.cs index 2857e877f9..7e8cde3bbe 100644 --- a/src/Plugins/Payments.CashOnDelivery/CashOnDeliveryPaymentProvider.cs +++ b/src/Plugins/Payments.CashOnDelivery/CashOnDeliveryPaymentProvider.cs @@ -7,24 +7,31 @@ using Grand.Domain.Orders; using Grand.Domain.Payments; using Grand.Infrastructure; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; namespace Payments.CashOnDelivery; public class CashOnDeliveryPaymentProvider : IPaymentProvider { private readonly CashOnDeliveryPaymentSettings _cashOnDeliveryPaymentSettings; - private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IContextAccessor _contextAccessor; + private readonly ICurrencyService _currencyService; + private readonly IPaymentTransactionService _paymentTransactionService; + private readonly IShoppingCartSubTotalService _shoppingCartSubTotalService; private readonly ITranslationService _translationService; public CashOnDeliveryPaymentProvider( ITranslationService translationService, - IHttpContextAccessor httpContextAccessor, + IShoppingCartSubTotalService shoppingCartSubTotalService, + ICurrencyService currencyService, + IContextAccessor contextAccessor, + IPaymentTransactionService paymentTransactionService, CashOnDeliveryPaymentSettings cashOnDeliveryPaymentSettings) { _translationService = translationService; - _httpContextAccessor = httpContextAccessor; + _shoppingCartSubTotalService = shoppingCartSubTotalService; + _currencyService = currencyService; + _contextAccessor = contextAccessor; + _paymentTransactionService = paymentTransactionService; _cashOnDeliveryPaymentSettings = cashOnDeliveryPaymentSettings; } @@ -86,28 +93,16 @@ public async Task GetAdditionalHandlingFee(IList cart) if (_cashOnDeliveryPaymentSettings.AdditionalFee <= 0) return _cashOnDeliveryPaymentSettings.AdditionalFee; - double result; if (_cashOnDeliveryPaymentSettings.AdditionalFeePercentage) { - //percentage - var orderTotalCalculationService = _httpContextAccessor.HttpContext!.RequestServices - .GetRequiredService(); - var subtotal = await orderTotalCalculationService.GetShoppingCartSubTotal(cart, true); - result = (float)subtotal.subTotalWithDiscount * (float)_cashOnDeliveryPaymentSettings.AdditionalFee / 100f; + //percentage of the subtotal, which is already in the working currency + var subtotal = await _shoppingCartSubTotalService.GetShoppingCartSubTotal(cart, true); + return (float)subtotal.subTotalWithDiscount * (float)_cashOnDeliveryPaymentSettings.AdditionalFee / 100f; } - else - { - //fixed value - result = _cashOnDeliveryPaymentSettings.AdditionalFee; - } - - if (!(result > 0)) return result; - var currencyService = _httpContextAccessor.HttpContext!.RequestServices.GetRequiredService(); - var workContext = _httpContextAccessor.HttpContext!.RequestServices.GetRequiredService().WorkContext; - result = await currencyService.ConvertFromPrimaryStoreCurrency(result, workContext.WorkingCurrency); - //return result; - return result; + //fixed value, kept in the primary store currency + return await _currencyService.ConvertFromPrimaryStoreCurrency(_cashOnDeliveryPaymentSettings.AdditionalFee, + _contextAccessor.WorkContext.WorkingCurrency); } public async Task> ValidatePaymentForm(IDictionary model) @@ -161,10 +156,8 @@ public async Task Void(PaymentTransaction paymentTransaction) /// Result public async Task CancelPayment(PaymentTransaction paymentTransaction) { - var paymentTransactionService = _httpContextAccessor.HttpContext!.RequestServices - .GetRequiredService(); paymentTransaction.TransactionStatus = TransactionStatus.Canceled; - await paymentTransactionService.UpdatePaymentTransaction(paymentTransaction); + await _paymentTransactionService.UpdatePaymentTransaction(paymentTransaction); } public async Task SupportCapture() diff --git a/src/Plugins/Shipping.ByWeight/ByWeightShippingProvider.cs b/src/Plugins/Shipping.ByWeight/ByWeightShippingProvider.cs index 484f70d6c9..5a0f8568c2 100644 --- a/src/Plugins/Shipping.ByWeight/ByWeightShippingProvider.cs +++ b/src/Plugins/Shipping.ByWeight/ByWeightShippingProvider.cs @@ -13,8 +13,6 @@ using Grand.Domain.Orders; using Grand.Domain.Shipping; using Grand.Infrastructure; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; using Shipping.ByWeight.Services; namespace Shipping.ByWeight; @@ -30,7 +28,8 @@ public ByWeightShippingCalcPlugin( IProductService productService, ICheckoutAttributeParser checkoutAttributeParser, ICurrencyService currencyService, - IHttpContextAccessor httpContextAccessor, + IPricingService pricingService, + IShippingByWeightService shippingByWeightService, ByWeightShippingSettings byWeightShippingSettings) { _shippingMethodService = shippingMethodService; @@ -39,7 +38,8 @@ public ByWeightShippingCalcPlugin( _productService = productService; _checkoutAttributeParser = checkoutAttributeParser; _currencyService = currencyService; - _httpContextAccessor = httpContextAccessor; + _pricingService = pricingService; + _shippingByWeightService = shippingByWeightService; _byWeightShippingSettings = byWeightShippingSettings; } @@ -50,10 +50,7 @@ public ByWeightShippingCalcPlugin( private async Task GetRate(double subTotal, double weight, string shippingMethodId, string storeId, string warehouseId, string countryId, string stateProvinceId, string zip) { - var shippingByWeightService = - _httpContextAccessor.HttpContext!.RequestServices.GetRequiredService(); - - var shippingByWeightRecord = await shippingByWeightService.FindRecord(shippingMethodId, + var shippingByWeightRecord = await _shippingByWeightService.FindRecord(shippingMethodId, storeId, warehouseId, countryId, stateProvinceId, zip, weight); if (shippingByWeightRecord == null) { @@ -94,7 +91,8 @@ public ByWeightShippingCalcPlugin( private readonly IProductService _productService; private readonly ICheckoutAttributeParser _checkoutAttributeParser; private readonly ICurrencyService _currencyService; - private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IPricingService _pricingService; + private readonly IShippingByWeightService _shippingByWeightService; private readonly ByWeightShippingSettings _byWeightShippingSettings; #endregion @@ -215,9 +213,6 @@ public async Task GetShippingOptions(GetShippingOptio var zip = getShippingOptionRequest.ShippingAddress.ZipPostalCode; double subTotal = 0; - var priceCalculationService = - _httpContextAccessor.HttpContext!.RequestServices.GetRequiredService(); - foreach (var packageItem in getShippingOptionRequest.Items) { if (packageItem.ShoppingCartItem.IsFreeShipping) @@ -225,7 +220,7 @@ public async Task GetShippingOptions(GetShippingOptio var product = await _productService.GetProductById(packageItem.ShoppingCartItem.ProductId); if (product != null) - subTotal += (await priceCalculationService.GetSubTotal(packageItem.ShoppingCartItem, product)).subTotal; + subTotal += (await _pricingService.GetSubTotal(packageItem.ShoppingCartItem, product)).subTotal; } var weight = await GetTotalWeight(getShippingOptionRequest); diff --git a/src/Plugins/Widgets.GoogleAnalytics/Components/WidgetsGoogleAnalyticsViewComponent.cs b/src/Plugins/Widgets.GoogleAnalytics/Components/WidgetsGoogleAnalyticsViewComponent.cs index b2f1c953cb..21831dba7f 100644 --- a/src/Plugins/Widgets.GoogleAnalytics/Components/WidgetsGoogleAnalyticsViewComponent.cs +++ b/src/Plugins/Widgets.GoogleAnalytics/Components/WidgetsGoogleAnalyticsViewComponent.cs @@ -5,9 +5,7 @@ using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Domain.Orders; using Grand.Infrastructure; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System.Globalization; @@ -16,23 +14,32 @@ namespace Widgets.GoogleAnalytics.Components; [ViewComponent(Name = "WidgetsGoogleAnalytics")] public class WidgetsGoogleAnalyticsViewComponent : ViewComponent { + private readonly ICategoryService _categoryService; private readonly ICookiePreference _cookiePreference; + private readonly ICountryService _countryService; private readonly GoogleAnalyticsEcommerceSettings _googleAnalyticsEcommerceSettings; - private readonly IHttpContextAccessor _httpContextAccessor; private readonly ILogger _logger; + private readonly IOrderService _orderService; + private readonly IProductService _productService; private readonly IContextAccessor _contextAccessor; public WidgetsGoogleAnalyticsViewComponent(IContextAccessor contextAccessor, ILogger logger, GoogleAnalyticsEcommerceSettings googleAnalyticsEcommerceSettings, ICookiePreference cookiePreference, - IHttpContextAccessor httpContextAccessor) + IOrderService orderService, + ICountryService countryService, + IProductService productService, + ICategoryService categoryService) { _contextAccessor = contextAccessor; _logger = logger; _googleAnalyticsEcommerceSettings = googleAnalyticsEcommerceSettings; _cookiePreference = cookiePreference; - _httpContextAccessor = httpContextAccessor; + _orderService = orderService; + _countryService = countryService; + _productService = productService; + _categoryService = categoryService; } public async Task InvokeAsync(string widgetZone, object additionalData = null) @@ -79,8 +86,7 @@ public async Task InvokeAsync(string widgetZone, object ad private async Task GetLastOrder() { - var orderService = _httpContextAccessor.HttpContext!.RequestServices.GetRequiredService(); - var order = (await orderService.SearchOrders(_contextAccessor.StoreContext.CurrentStore.Id, + var order = (await _orderService.SearchOrders(_contextAccessor.StoreContext.CurrentStore.Id, customerId: _contextAccessor.WorkContext.CurrentCustomer.Id, pageSize: 1)).FirstOrDefault(); return order; } @@ -103,9 +109,7 @@ private async Task GetEcommerceScript(Order order) if (order != null) { - var countryService = - _httpContextAccessor.HttpContext!.RequestServices.GetRequiredService(); - var country = await countryService.GetCountryById(order.BillingAddress?.CountryId); + var country = await _countryService.GetCountryById(order.BillingAddress?.CountryId); var state = country?.StateProvinces.FirstOrDefault(x => x.Id == order.BillingAddress?.StateProvinceId); var analyticsEcommerceScript = _googleAnalyticsEcommerceSettings.EcommerceScript + "\n"; @@ -135,21 +139,16 @@ private async Task GetEcommerceScript(Order order) var sb = new StringBuilder(); - var productService = - _httpContextAccessor.HttpContext!.RequestServices.GetRequiredService(); - var categoryService = - _httpContextAccessor.HttpContext!.RequestServices.GetRequiredService(); - foreach (var item in order.OrderItems) { - var product = await productService.GetProductById(item.ProductId); + var product = await _productService.GetProductById(item.ProductId); var analyticsEcommerceDetailScript = _googleAnalyticsEcommerceSettings.EcommerceDetailScript; //get category var category = ""; if (product.ProductCategories.Any()) { var defaultProductCategory = - await categoryService.GetCategoryById(product.ProductCategories.MinBy(x => x.DisplayOrder) + await _categoryService.GetCategoryById(product.ProductCategories.MinBy(x => x.DisplayOrder) .CategoryId); if (defaultProductCategory != null) category = defaultProductCategory.Name; diff --git a/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetShoppingCartItemsSubTotalQueryHandlerTests.cs b/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetShoppingCartItemsSubTotalQueryHandlerTests.cs new file mode 100644 index 0000000000..2e1e4c61c1 --- /dev/null +++ b/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetShoppingCartItemsSubTotalQueryHandlerTests.cs @@ -0,0 +1,38 @@ +using Grand.Business.Catalog.Queries.Handlers; +using Grand.Business.Core.Interfaces.Catalog.Prices; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Queries.Catalog; +using Grand.Business.Core.Utilities.Catalog; +using Grand.Domain.Catalog; +using Grand.Domain.Orders; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Business.Catalog.Tests.Queries.Handlers; + +[TestClass] +public class GetShoppingCartItemsSubTotalQueryHandlerTests +{ + [TestMethod] + public async Task Handle_SumsSubtotalsWithoutDiscounts_SkipsMissingProducts() + { + var product = new Product { Id = "p1" }; + var productService = new Mock(); + productService.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + var pricingService = new Mock(); + pricingService.Setup(p => p.GetSubTotal(It.IsAny(), product, false)) + .ReturnsAsync((40d, 0d, new List())); + var handler = new GetShoppingCartItemsSubTotalQueryHandler(pricingService.Object, productService.Object); + + var subTotal = await handler.Handle(new GetShoppingCartItemsSubTotalQuery { + Items = [ + new ShoppingCartItem { ProductId = "p1" }, + new ShoppingCartItem { ProductId = "p1" }, + new ShoppingCartItem { ProductId = "missing" } + ] + }, CancellationToken.None); + + Assert.AreEqual(80, subTotal); + pricingService.Verify(p => p.GetSubTotal(It.IsAny(), It.IsAny(), true), Times.Never); + } +} diff --git a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderCalculationServiceTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderCalculationServiceTests.cs index e78c083e9f..243772127a 100644 --- a/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderCalculationServiceTests.cs +++ b/src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderCalculationServiceTests.cs @@ -76,11 +76,16 @@ public void Init() _workContextMock.Setup(c => c.WorkContext.WorkingCurrency).Returns(() => new Currency()); _workContextMock.Setup(c => c.WorkContext.TaxDisplayType).Returns(() => TaxDisplayType.ExcludingTax); - _service = new OrderCalculationService(_workContextMock.Object, _pricingServiceMock.Object, + var shoppingCartSubTotalService = new ShoppingCartSubTotalService(_workContextMock.Object, + _pricingServiceMock.Object, _taxServiceMock.Object, _checkoutAttributeParserMock.Object, + _discountServiceMock.Object, _discountValidationService.Object, _productServiceMock.Object, + _currencyServiceMock.Object, _shoppingCartSettings, _catalogSettings); + + _service = new OrderCalculationService(_workContextMock.Object, shoppingCartSubTotalService, _taxServiceMock.Object, _shippingServiceMock.Object, - _paymentServiceMock.Object, _checkoutAttributeParserMock.Object, _discountServiceMock.Object, + _paymentServiceMock.Object, _discountServiceMock.Object, _giftVoucherServiceMock.Object, - _loyaltyPointsServiceMock.Object, _productServiceMock.Object, _currencyServiceMock.Object, + _loyaltyPointsServiceMock.Object, _currencyServiceMock.Object, _groupServiceMock.Object, _discountValidationService.Object, _taxSettings, _loyaltyPointsSettings, _shippingSettings, _shoppingCartSettings, _catalogSettings); } diff --git a/src/Tests/Grand.Modules.Tests/Grand.Modules.Tests.csproj b/src/Tests/Grand.Modules.Tests/Grand.Modules.Tests.csproj index c19a8903db..7bce21f27d 100644 --- a/src/Tests/Grand.Modules.Tests/Grand.Modules.Tests.csproj +++ b/src/Tests/Grand.Modules.Tests/Grand.Modules.Tests.csproj @@ -23,6 +23,9 @@ + + + diff --git a/src/Tests/Grand.Modules.Tests/Plugins/DiscountRules/ShoppingCartDiscountRuleTests.cs b/src/Tests/Grand.Modules.Tests/Plugins/DiscountRules/ShoppingCartDiscountRuleTests.cs new file mode 100644 index 0000000000..7be45ab6a2 --- /dev/null +++ b/src/Tests/Grand.Modules.Tests/Plugins/DiscountRules/ShoppingCartDiscountRuleTests.cs @@ -0,0 +1,78 @@ +using DiscountRules.Standard.Providers; +using Grand.Business.Core.Queries.Catalog; +using Grand.Business.Core.Utilities.Catalog; +using Grand.Domain.Customers; +using Grand.Domain.Discounts; +using Grand.Domain.Orders; +using Grand.Domain.Stores; +using Grand.Mediator; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Modules.Tests.Plugins.DiscountRules; + +[TestClass] +public class ShoppingCartDiscountRuleTests +{ + private Mock _mediator; + private ShoppingCartDiscountRule _rule; + + [TestInitialize] + public void Init() + { + _mediator = new Mock(); + _rule = new ShoppingCartDiscountRule(_mediator.Object, new ShoppingCartSettings()); + } + + private static DiscountRuleValidationRequest Request(Customer customer, string spentAmount = "100") + { + return new DiscountRuleValidationRequest { + Discount = new Discount(), + DiscountRule = new DiscountRule { Metadata = spentAmount }, + Customer = customer, + Store = new Store { Id = "store1" } + }; + } + + [TestMethod] + public async Task CheckRequirement_SumsCartOfRequestCustomerInRequestStore() + { + var customer = new Customer(); + var inStore = new ShoppingCartItem { ShoppingCartTypeId = ShoppingCartType.ShoppingCart, StoreId = "store1" }; + customer.ShoppingCartItems.Add(inStore); + customer.ShoppingCartItems.Add(new ShoppingCartItem { ShoppingCartTypeId = ShoppingCartType.ShoppingCart, StoreId = "store2" }); + customer.ShoppingCartItems.Add(new ShoppingCartItem { ShoppingCartTypeId = ShoppingCartType.Wishlist, StoreId = "store1" }); + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(150d); + + var result = await _rule.CheckRequirement(Request(customer)); + + Assert.IsTrue(result.IsValid); + _mediator.Verify(m => m.Send( + It.Is(q => q.Items.Count == 1 && q.Items[0] == inStore), + It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task CheckRequirement_SubtotalNotAboveRequirement_IsNotValid() + { + var customer = new Customer(); + customer.ShoppingCartItems.Add(new ShoppingCartItem { ShoppingCartTypeId = ShoppingCartType.ShoppingCart, StoreId = "store1" }); + _mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(100d); + + var result = await _rule.CheckRequirement(Request(customer)); + + Assert.IsFalse(result.IsValid); + } + + [TestMethod] + public async Task CheckRequirement_NoCustomer_IsNotValid() + { + var result = await _rule.CheckRequirement(Request(null)); + + Assert.IsFalse(result.IsValid); + _mediator.Verify(m => m.Send(It.IsAny(), It.IsAny()), + Times.Never); + } +} diff --git a/src/Tests/Grand.Modules.Tests/Plugins/Payments/BrainTreePaymentProviderTests.cs b/src/Tests/Grand.Modules.Tests/Plugins/Payments/BrainTreePaymentProviderTests.cs new file mode 100644 index 0000000000..f3ea2c7ba2 --- /dev/null +++ b/src/Tests/Grand.Modules.Tests/Plugins/Payments/BrainTreePaymentProviderTests.cs @@ -0,0 +1,74 @@ +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Checkout.Payments; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Utilities.Catalog; +using Grand.Domain.Directory; +using Grand.Domain.Orders; +using Grand.Infrastructure; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using Payments.BrainTree; + +namespace Grand.Modules.Tests.Plugins.Payments; + +[TestClass] +public class BrainTreePaymentProviderTests +{ + private Mock _currencyService; + private ServiceProvider _root; + private IServiceScope _scope; + private BrainTreePaymentSettings _settings; + private Mock _subTotalService; + + [TestInitialize] + public void Init() + { + _settings = new BrainTreePaymentSettings(); + _subTotalService = new Mock(); + _currencyService = new Mock(); + var contextAccessor = new Mock(); + contextAccessor.Setup(c => c.WorkContext.WorkingCurrency).Returns(new Currency()); + + var services = new ServiceCollection(); + services.AddSingleton(_settings); + services.AddSingleton(Mock.Of()); + services.AddSingleton(Mock.Of()); + //the card form lives in the session, but no request is running here: HttpContext is null + services.AddSingleton(new HttpContextAccessor()); + services.AddSingleton(_subTotalService.Object); + services.AddSingleton(_currencyService.Object); + services.AddSingleton(contextAccessor.Object); + services.AddScoped(); + + _root = services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true }); + _scope = _root.CreateScope(); + } + + [TestCleanup] + public void Cleanup() + { + _scope.Dispose(); + _root.Dispose(); + } + + [TestMethod] + public async Task GetAdditionalHandlingFee_Percentage_WorksWithoutHttpContext() + { + _settings.AdditionalFee = 2; + _settings.AdditionalFeePercentage = true; + var cart = new List { new() }; + _subTotalService.Setup(s => s.GetShoppingCartSubTotal(cart, true)) + .ReturnsAsync((0d, new List(), 500d, 500d, new SortedDictionary())); + + var provider = _scope.ServiceProvider.GetRequiredService(); + var fee = await provider.GetAdditionalHandlingFee(cart); + + Assert.AreEqual(10, fee, 0.0001); + _currencyService.Verify(c => c.ConvertFromPrimaryStoreCurrency(It.IsAny(), It.IsAny()), + Times.Never); + } +} diff --git a/src/Tests/Grand.Modules.Tests/Plugins/Payments/CashOnDeliveryPaymentProviderTests.cs b/src/Tests/Grand.Modules.Tests/Plugins/Payments/CashOnDeliveryPaymentProviderTests.cs new file mode 100644 index 0000000000..7d2b57ca78 --- /dev/null +++ b/src/Tests/Grand.Modules.Tests/Plugins/Payments/CashOnDeliveryPaymentProviderTests.cs @@ -0,0 +1,108 @@ +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Checkout.Payments; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Utilities.Catalog; +using Grand.Domain.Directory; +using Grand.Domain.Orders; +using Grand.Domain.Payments; +using Grand.Infrastructure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using Payments.CashOnDelivery; + +namespace Grand.Modules.Tests.Plugins.Payments; + +[TestClass] +public class CashOnDeliveryPaymentProviderTests +{ + private readonly Currency _workingCurrency = new() { CurrencyCode = "EUR" }; + private Mock _currencyService; + private Mock _paymentTransactionService; + private ServiceProvider _root; + private IServiceScope _scope; + private CashOnDeliveryPaymentSettings _settings; + private Mock _subTotalService; + + [TestInitialize] + public void Init() + { + _settings = new CashOnDeliveryPaymentSettings(); + _subTotalService = new Mock(); + _currencyService = new Mock(); + _paymentTransactionService = new Mock(); + var contextAccessor = new Mock(); + contextAccessor.Setup(c => c.WorkContext.WorkingCurrency).Returns(_workingCurrency); + + //no IHttpContextAccessor: the provider has to work outside a request + var services = new ServiceCollection(); + services.AddSingleton(_settings); + services.AddSingleton(Mock.Of()); + services.AddSingleton(_subTotalService.Object); + services.AddSingleton(_currencyService.Object); + services.AddSingleton(_paymentTransactionService.Object); + services.AddSingleton(contextAccessor.Object); + new StartupApplication().ConfigureServices(services, new ConfigurationBuilder().Build()); + + _root = services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true }); + _scope = _root.CreateScope(); + } + + [TestCleanup] + public void Cleanup() + { + _scope.Dispose(); + _root.Dispose(); + } + + private IPaymentProvider Provider => _scope.ServiceProvider.GetRequiredService(); + + [TestMethod] + public void Provider_ResolvesFromContainerWithoutHttpContext() + { + Assert.IsInstanceOfType(Provider); + } + + [TestMethod] + public async Task GetAdditionalHandlingFee_Percentage_IsShareOfSubtotalWithoutSecondConversion() + { + _settings.AdditionalFee = 10; + _settings.AdditionalFeePercentage = true; + var cart = new List { new() }; + _subTotalService.Setup(s => s.GetShoppingCartSubTotal(cart, true)) + .ReturnsAsync((0d, new List(), 200d, 180d, new SortedDictionary())); + + var fee = await Provider.GetAdditionalHandlingFee(cart); + + Assert.AreEqual(18, fee, 0.0001); + //the subtotal is already in the working currency + _currencyService.Verify(c => c.ConvertFromPrimaryStoreCurrency(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task GetAdditionalHandlingFee_FixedValue_IsConvertedToWorkingCurrency() + { + _settings.AdditionalFee = 5; + _currencyService.Setup(c => c.ConvertFromPrimaryStoreCurrency(5, _workingCurrency)).ReturnsAsync(20); + + var fee = await Provider.GetAdditionalHandlingFee(new List()); + + Assert.AreEqual(20, fee); + _subTotalService.Verify(s => s.GetShoppingCartSubTotal(It.IsAny>(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task CancelPayment_SavesTransactionAsCanceled() + { + var transaction = new PaymentTransaction { TransactionStatus = TransactionStatus.Pending }; + + await Provider.CancelPayment(transaction); + + Assert.AreEqual(TransactionStatus.Canceled, transaction.TransactionStatus); + _paymentTransactionService.Verify(s => s.UpdatePaymentTransaction(transaction), Times.Once); + } +} diff --git a/src/Tests/Grand.Modules.Tests/Plugins/PluginDependencyCycleTests.cs b/src/Tests/Grand.Modules.Tests/Plugins/PluginDependencyCycleTests.cs new file mode 100644 index 0000000000..7750906ec4 --- /dev/null +++ b/src/Tests/Grand.Modules.Tests/Plugins/PluginDependencyCycleTests.cs @@ -0,0 +1,114 @@ +using Grand.Business.Catalog.Services.Discounts; +using Grand.Business.Catalog.Services.Prices; +using Grand.Business.Checkout.Services.Orders; +using Grand.Business.Checkout.Services.Payments; +using Grand.Business.Core.Interfaces.Catalog.Brands; +using Grand.Business.Core.Interfaces.Catalog.Categories; +using Grand.Business.Core.Interfaces.Catalog.Collections; +using Grand.Business.Core.Interfaces.Catalog.Discounts; +using Grand.Business.Core.Interfaces.Catalog.Prices; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Catalog.Tax; +using Grand.Business.Core.Interfaces.Checkout.CheckoutAttributes; +using Grand.Business.Core.Interfaces.Checkout.GiftVouchers; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Checkout.Payments; +using Grand.Business.Core.Interfaces.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.Customers; +using Grand.Data; +using Grand.Domain.Catalog; +using Grand.Domain.Customers; +using Grand.Domain.Discounts; +using Grand.Domain.Orders; +using Grand.Domain.Payments; +using Grand.Domain.Shipping; +using Grand.Domain.Tax; +using Grand.Infrastructure; +using Grand.Mediator; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using Payments.BrainTree; +using Payments.CashOnDelivery; + +namespace Grand.Modules.Tests.Plugins; + +/// +/// The real core services next to the real plugins, with only the leaves mocked: +/// a provider that depends back on the service that loads it fails ValidateOnBuild. +/// +[TestClass] +public class PluginDependencyCycleTests +{ + private static void AddMocks(IServiceCollection services, params Type[] types) + { + foreach (var type in types) + { + var mock = (Mock)Activator.CreateInstance(typeof(Mock<>).MakeGenericType(type))!; + services.AddSingleton(type, mock.Object); + } + } + + private static ServiceProvider Build(IServiceCollection services) + { + return services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true }); + } + + [TestMethod] + public void PaymentProviders_DoNotDependBackOnOrderCalculation() + { + var services = new ServiceCollection(); + 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(IPaymentTransactionService), + typeof(ICustomerService), typeof(IHttpContextAccessor)); + 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 BrainTreePaymentSettings()); + + using var root = Build(services); + using var scope = root.CreateScope(); + + Assert.IsNotNull(scope.ServiceProvider.GetRequiredService()); + } + + [TestMethod] + public void StandardDiscountRules_DoNotDependBackOnPricing() + { + var services = new ServiceCollection(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + new global::DiscountRules.Standard.StartupApplication().ConfigureServices(services, + new ConfigurationBuilder().Build()); + AddMocks(services, typeof(IContextAccessor), typeof(IProductService), typeof(IMediator), + typeof(ICurrencyService), typeof(IDiscountService), typeof(ICategoryService), typeof(IBrandService), + typeof(ICollectionService), typeof(IVendorService), typeof(IRepository), + typeof(IOrderService), typeof(ITranslationService)); + services.AddSingleton(new ShoppingCartSettings()); + services.AddSingleton(new CatalogSettings()); + + using var root = Build(services); + using var scope = root.CreateScope(); + + Assert.IsNotNull(scope.ServiceProvider.GetRequiredService()); + } +} From 38beba0fd1ed425787c87a8827ee031ae4eb4315 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 15 Oct 2026 21:14:04 +0200 Subject: [PATCH 2/2] Price the discount rule cart for the request customer; guard deleted products - GetShoppingCartItemsSubTotalQuery carries Customer and Store; the handler prices each item for them in the primary store currency (the currency the spent-amount requirement is entered in) instead of the ambient customer. - OrderCalculationService.GetShoppingCartSubTotal is no longer virtual: payment providers read IShoppingCartSubTotalService, so an override there would make the order total and the payment fee disagree. Customize ShoppingCartSubTotalService instead. - Google Analytics ecommerce script no longer throws for an order item whose product was deleted. - Cycle test for shipping providers (ByWeight -> pricing -> discount rules). --- ...etShoppingCartItemsSubTotalQueryHandler.cs | 24 ++++++++++-- .../Orders/OrderCalculationService.cs | 7 +++- .../GetShoppingCartItemsSubTotalQuery.cs | 7 +++- .../Providers/ShoppingCartDiscountRule.cs | 6 ++- .../WidgetsGoogleAnalyticsViewComponent.cs | 6 +-- ...ppingCartItemsSubTotalQueryHandlerTests.cs | 34 ++++++++++++----- .../Grand.Modules.Tests.csproj | 3 +- .../ShoppingCartDiscountRuleTests.cs | 5 ++- .../Plugins/PluginDependencyCycleTests.cs | 37 ++++++++++++++++++- 9 files changed, 107 insertions(+), 22 deletions(-) diff --git a/src/Business/Grand.Business.Catalog/Queries/Handlers/GetShoppingCartItemsSubTotalQueryHandler.cs b/src/Business/Grand.Business.Catalog/Queries/Handlers/GetShoppingCartItemsSubTotalQueryHandler.cs index 905b8bcbce..180ee47713 100644 --- a/src/Business/Grand.Business.Catalog/Queries/Handlers/GetShoppingCartItemsSubTotalQueryHandler.cs +++ b/src/Business/Grand.Business.Catalog/Queries/Handlers/GetShoppingCartItemsSubTotalQueryHandler.cs @@ -1,5 +1,6 @@ using Grand.Business.Core.Interfaces.Catalog.Prices; using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Queries.Catalog; using Grand.Mediator; @@ -7,23 +8,40 @@ namespace Grand.Business.Catalog.Queries.Handlers; public class GetShoppingCartItemsSubTotalQueryHandler : IRequestHandler { + private readonly ICurrencyService _currencyService; private readonly IPricingService _pricingService; private readonly IProductService _productService; - public GetShoppingCartItemsSubTotalQueryHandler(IPricingService pricingService, IProductService productService) + public GetShoppingCartItemsSubTotalQueryHandler( + IPricingService pricingService, + IProductService productService, + ICurrencyService currencyService) { _pricingService = pricingService; _productService = productService; + _currencyService = currencyService; } public async Task Handle(GetShoppingCartItemsSubTotalQuery request, CancellationToken cancellationToken) { + ArgumentNullException.ThrowIfNull(request.Customer); + ArgumentNullException.ThrowIfNull(request.Store); + + //priced for the customer of the request, not the ambient one, in the currency the requirement is entered in + var currency = await _currencyService.GetPrimaryStoreCurrency(); + double subTotal = 0; foreach (var item in request.Items) { var product = await _productService.GetProductById(item.ProductId); - if (product != null) - subTotal += (await _pricingService.GetSubTotal(item, product, false)).subTotal; + if (product == null) + continue; + + //without discounts the subtotal is the unit price times the quantity, as in IPricingService.GetSubTotal + var unitPrice = await _pricingService.GetUnitPrice(product, request.Customer, request.Store, currency, + item.ShoppingCartTypeId, item.Quantity, item.Attributes, item.EnteredPrice, + item.RentalStartDateUtc, item.RentalEndDateUtc, false); + subTotal += unitPrice.unitprice * item.Quantity; } return subTotal; diff --git a/src/Business/Grand.Business.Checkout/Services/Orders/OrderCalculationService.cs b/src/Business/Grand.Business.Checkout/Services/Orders/OrderCalculationService.cs index 46a2253159..42c6842b16 100644 --- a/src/Business/Grand.Business.Checkout/Services/Orders/OrderCalculationService.cs +++ b/src/Business/Grand.Business.Checkout/Services/Orders/OrderCalculationService.cs @@ -240,7 +240,12 @@ private async Task> GetActiveGiftVouchers(Customer customer, /// /// Cart /// A value indicating whether calculated price should include tax - public virtual Task<(double discountAmount, List appliedDiscounts, + /// + /// Not virtual: payment providers read the subtotal from , + /// so an override here would make the order total and the payment fee disagree. + /// Customize the subtotal (and its discount) in instead. + /// + public Task<(double discountAmount, List appliedDiscounts, double subTotalWithoutDiscount, double subTotalWithDiscount, SortedDictionary taxRates)> GetShoppingCartSubTotal(IList cart, bool includingTax) { diff --git a/src/Business/Grand.Business.Core/Queries/Catalog/GetShoppingCartItemsSubTotalQuery.cs b/src/Business/Grand.Business.Core/Queries/Catalog/GetShoppingCartItemsSubTotalQuery.cs index b9528e6c1f..5998cd7936 100644 --- a/src/Business/Grand.Business.Core/Queries/Catalog/GetShoppingCartItemsSubTotalQuery.cs +++ b/src/Business/Grand.Business.Core/Queries/Catalog/GetShoppingCartItemsSubTotalQuery.cs @@ -1,14 +1,19 @@ +using Grand.Domain.Customers; using Grand.Domain.Orders; +using Grand.Domain.Stores; using Grand.Mediator; namespace Grand.Business.Core.Queries.Catalog; /// -/// Sum of the shopping cart items' subtotals, without discounts. +/// Sum of the shopping cart items' subtotals, without discounts, priced for the given customer and store +/// in the primary store currency. /// A discount rule sends this instead of injecting IPricingService: /// the pricing service depends on discounts, so a rule that depends on it closes a DI cycle. /// public class GetShoppingCartItemsSubTotalQuery : IRequest { + public Customer Customer { get; set; } + public Store Store { get; set; } public IList Items { get; set; } = new List(); } diff --git a/src/Plugins/DiscountRules.Standard/Providers/ShoppingCartDiscountRule.cs b/src/Plugins/DiscountRules.Standard/Providers/ShoppingCartDiscountRule.cs index 8b799c41be..0c7ac6761c 100644 --- a/src/Plugins/DiscountRules.Standard/Providers/ShoppingCartDiscountRule.cs +++ b/src/Plugins/DiscountRules.Standard/Providers/ShoppingCartDiscountRule.cs @@ -54,7 +54,11 @@ public async Task CheckRequirement(DiscountRuleVal return result; } - var spentAmount = await _mediator.Send(new GetShoppingCartItemsSubTotalQuery { Items = cart }); + var spentAmount = await _mediator.Send(new GetShoppingCartItemsSubTotalQuery { + Customer = request.Customer, + Store = request.Store, + Items = cart + }); result.IsValid = spentAmount > spentAmountRequirement; return result; diff --git a/src/Plugins/Widgets.GoogleAnalytics/Components/WidgetsGoogleAnalyticsViewComponent.cs b/src/Plugins/Widgets.GoogleAnalytics/Components/WidgetsGoogleAnalyticsViewComponent.cs index 21831dba7f..3a8a7b8714 100644 --- a/src/Plugins/Widgets.GoogleAnalytics/Components/WidgetsGoogleAnalyticsViewComponent.cs +++ b/src/Plugins/Widgets.GoogleAnalytics/Components/WidgetsGoogleAnalyticsViewComponent.cs @@ -143,9 +143,9 @@ private async Task GetEcommerceScript(Order order) { var product = await _productService.GetProductById(item.ProductId); var analyticsEcommerceDetailScript = _googleAnalyticsEcommerceSettings.EcommerceDetailScript; - //get category + //get category; the product may have been deleted since the order was placed var category = ""; - if (product.ProductCategories.Any()) + if (product != null && product.ProductCategories.Any()) { var defaultProductCategory = await _categoryService.GetCategoryById(product.ProductCategories.MinBy(x => x.DisplayOrder) @@ -161,7 +161,7 @@ await _categoryService.GetCategoryById(product.ProductCategories.MinBy(x => x.Di analyticsEcommerceDetailScript = analyticsEcommerceDetailScript.Replace("{PRODUCTID}", FixIllegalJavaScriptChars(item.ProductId)); analyticsEcommerceDetailScript = - analyticsEcommerceDetailScript.Replace("{PRODUCTNAME}", FixIllegalJavaScriptChars(product.Name)); + analyticsEcommerceDetailScript.Replace("{PRODUCTNAME}", FixIllegalJavaScriptChars(product?.Name ?? "")); analyticsEcommerceDetailScript = analyticsEcommerceDetailScript.Replace("{CATEGORYNAME}", FixIllegalJavaScriptChars(category)); var unitPrice = _googleAnalyticsEcommerceSettings.IncludingTax diff --git a/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetShoppingCartItemsSubTotalQueryHandlerTests.cs b/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetShoppingCartItemsSubTotalQueryHandlerTests.cs index 2e1e4c61c1..5bda515fef 100644 --- a/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetShoppingCartItemsSubTotalQueryHandlerTests.cs +++ b/src/Tests/Grand.Business.Catalog.Tests/Queries/Handlers/GetShoppingCartItemsSubTotalQueryHandlerTests.cs @@ -1,10 +1,15 @@ using Grand.Business.Catalog.Queries.Handlers; using Grand.Business.Core.Interfaces.Catalog.Prices; using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Queries.Catalog; using Grand.Business.Core.Utilities.Catalog; using Grand.Domain.Catalog; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Domain.Directory; using Grand.Domain.Orders; +using Grand.Domain.Stores; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -14,25 +19,36 @@ namespace Grand.Business.Catalog.Tests.Queries.Handlers; public class GetShoppingCartItemsSubTotalQueryHandlerTests { [TestMethod] - public async Task Handle_SumsSubtotalsWithoutDiscounts_SkipsMissingProducts() + public async Task Handle_PricesItemsForRequestCustomerInPrimaryCurrency_SkipsMissingProducts() { var product = new Product { Id = "p1" }; + var customer = new Customer { Id = "c1" }; + var store = new Store { Id = "s1" }; + var primaryCurrency = new Currency { Id = "usd" }; var productService = new Mock(); productService.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + var currencyService = new Mock(); + currencyService.Setup(c => c.GetPrimaryStoreCurrency()).ReturnsAsync(primaryCurrency); var pricingService = new Mock(); - pricingService.Setup(p => p.GetSubTotal(It.IsAny(), product, false)) - .ReturnsAsync((40d, 0d, new List())); - var handler = new GetShoppingCartItemsSubTotalQueryHandler(pricingService.Object, productService.Object); + pricingService.Setup(p => p.GetUnitPrice(product, customer, store, primaryCurrency, + ShoppingCartType.ShoppingCart, It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny(), false)) + .ReturnsAsync((20d, 0d, new List())); + var handler = new GetShoppingCartItemsSubTotalQueryHandler(pricingService.Object, productService.Object, + currencyService.Object); var subTotal = await handler.Handle(new GetShoppingCartItemsSubTotalQuery { + Customer = customer, + Store = store, Items = [ - new ShoppingCartItem { ProductId = "p1" }, - new ShoppingCartItem { ProductId = "p1" }, - new ShoppingCartItem { ProductId = "missing" } + new ShoppingCartItem { ProductId = "p1", Quantity = 2, ShoppingCartTypeId = ShoppingCartType.ShoppingCart }, + new ShoppingCartItem { ProductId = "p1", Quantity = 1, ShoppingCartTypeId = ShoppingCartType.ShoppingCart }, + new ShoppingCartItem { ProductId = "missing", Quantity = 1, ShoppingCartTypeId = ShoppingCartType.ShoppingCart } ] }, CancellationToken.None); - Assert.AreEqual(80, subTotal); - pricingService.Verify(p => p.GetSubTotal(It.IsAny(), It.IsAny(), true), Times.Never); + Assert.AreEqual(60, subTotal); + pricingService.Verify(p => p.GetSubTotal(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); } } diff --git a/src/Tests/Grand.Modules.Tests/Grand.Modules.Tests.csproj b/src/Tests/Grand.Modules.Tests/Grand.Modules.Tests.csproj index 7bce21f27d..7a9e8b4ee4 100644 --- a/src/Tests/Grand.Modules.Tests/Grand.Modules.Tests.csproj +++ b/src/Tests/Grand.Modules.Tests/Grand.Modules.Tests.csproj @@ -1,4 +1,4 @@ - + false @@ -26,6 +26,7 @@ + diff --git a/src/Tests/Grand.Modules.Tests/Plugins/DiscountRules/ShoppingCartDiscountRuleTests.cs b/src/Tests/Grand.Modules.Tests/Plugins/DiscountRules/ShoppingCartDiscountRuleTests.cs index 7be45ab6a2..3c15debe94 100644 --- a/src/Tests/Grand.Modules.Tests/Plugins/DiscountRules/ShoppingCartDiscountRuleTests.cs +++ b/src/Tests/Grand.Modules.Tests/Plugins/DiscountRules/ShoppingCartDiscountRuleTests.cs @@ -1,4 +1,4 @@ -using DiscountRules.Standard.Providers; +using DiscountRules.Standard.Providers; using Grand.Business.Core.Queries.Catalog; using Grand.Business.Core.Utilities.Catalog; using Grand.Domain.Customers; @@ -49,7 +49,8 @@ public async Task CheckRequirement_SumsCartOfRequestCustomerInRequestStore() Assert.IsTrue(result.IsValid); _mediator.Verify(m => m.Send( - It.Is(q => q.Items.Count == 1 && q.Items[0] == inStore), + It.Is(q => + q.Customer == customer && q.Store.Id == "store1" && q.Items.Count == 1 && q.Items[0] == inStore), It.IsAny()), Times.Once); } diff --git a/src/Tests/Grand.Modules.Tests/Plugins/PluginDependencyCycleTests.cs b/src/Tests/Grand.Modules.Tests/Plugins/PluginDependencyCycleTests.cs index 7750906ec4..ce46aa14fe 100644 --- a/src/Tests/Grand.Modules.Tests/Plugins/PluginDependencyCycleTests.cs +++ b/src/Tests/Grand.Modules.Tests/Plugins/PluginDependencyCycleTests.cs @@ -1,7 +1,8 @@ -using Grand.Business.Catalog.Services.Discounts; +using Grand.Business.Catalog.Services.Discounts; using Grand.Business.Catalog.Services.Prices; using Grand.Business.Checkout.Services.Orders; using Grand.Business.Checkout.Services.Payments; +using Grand.Business.Checkout.Services.Shipping; using Grand.Business.Core.Interfaces.Catalog.Brands; using Grand.Business.Core.Interfaces.Catalog.Categories; using Grand.Business.Core.Interfaces.Catalog.Collections; @@ -31,10 +32,13 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using Payments.BrainTree; using Payments.CashOnDelivery; +using Shipping.ByWeight; +using Shipping.ByWeight.Services; namespace Grand.Modules.Tests.Plugins; @@ -111,4 +115,35 @@ public void StandardDiscountRules_DoNotDependBackOnPricing() Assert.IsNotNull(scope.ServiceProvider.GetRequiredService()); } + + [TestMethod] + public void ShippingProviders_DoNotDependBackOnShipping() + { + //ShippingService builds every provider eagerly; ByWeight pulls in pricing, and pricing every discount rule + var services = new ServiceCollection(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + new global::DiscountRules.Standard.StartupApplication().ConfigureServices(services, + new ConfigurationBuilder().Build()); + AddMocks(services, typeof(ILogger), typeof(ICountryService), + typeof(IShippingMethodService), typeof(IShippingByWeightService), typeof(ICheckoutAttributeParser), + typeof(IContextAccessor), typeof(IProductService), typeof(IMediator), typeof(ICurrencyService), + typeof(IDiscountService), typeof(ICategoryService), typeof(IBrandService), typeof(ICollectionService), + typeof(IVendorService), typeof(IRepository), typeof(IOrderService), + typeof(ITranslationService)); + services.AddSingleton(new ShippingProviderSettings()); + services.AddSingleton(new ShippingSettings()); + services.AddSingleton(new ByWeightShippingSettings()); + services.AddSingleton(new ShoppingCartSettings()); + services.AddSingleton(new CatalogSettings()); + + using var root = Build(services); + using var scope = root.CreateScope(); + + Assert.IsNotNull(scope.ServiceProvider.GetRequiredService()); + } }