using Nop.Core.Caching; using Nop.Core.Domain.Catalog; using Nop.Core.Domain.Customers; using Nop.Core.Domain.Directory; using Nop.Core.Domain.Discounts; using Nop.Core.Domain.Stores; using Nop.Services.Customers; using Nop.Services.Directory; using Nop.Services.Discounts; namespace Nop.Services.Catalog; /// /// Price calculation service /// public partial class PriceCalculationService : IPriceCalculationService { #region Fields protected readonly CatalogSettings _catalogSettings; protected readonly CurrencySettings _currencySettings; protected readonly ICategoryService _categoryService; protected readonly ICurrencyService _currencyService; protected readonly ICustomerService _customerService; protected readonly IDiscountService _discountService; protected readonly IManufacturerService _manufacturerService; protected readonly IProductAttributeParser _productAttributeParser; protected readonly IProductService _productService; protected readonly IStaticCacheManager _staticCacheManager; #endregion #region Ctor public PriceCalculationService(CatalogSettings catalogSettings, CurrencySettings currencySettings, ICategoryService categoryService, ICurrencyService currencyService, ICustomerService customerService, IDiscountService discountService, IManufacturerService manufacturerService, IProductAttributeParser productAttributeParser, IProductService productService, IStaticCacheManager staticCacheManager) { _catalogSettings = catalogSettings; _currencySettings = currencySettings; _categoryService = categoryService; _currencyService = currencyService; _customerService = customerService; _discountService = discountService; _manufacturerService = manufacturerService; _productAttributeParser = productAttributeParser; _productService = productService; _staticCacheManager = staticCacheManager; } #endregion #region Utilities /// /// Gets allowed discounts applied to product /// /// Product /// Customer /// /// A task that represents the asynchronous operation /// The task result contains the discounts /// protected virtual async Task> GetAllowedDiscountsAppliedToProductAsync(Product product, Customer customer) { var allowedDiscounts = new List(); if (_catalogSettings.IgnoreDiscounts) return allowedDiscounts; var couponCodesToValidate = await _customerService.ParseAppliedDiscountCouponCodesAsync(customer); foreach (var discount in await _discountService.GetAppliedDiscountsAsync(product)) if (discount.DiscountType == DiscountType.AssignedToSkus && (await _discountService.ValidateDiscountAsync(discount, customer, couponCodesToValidate)).IsValid) allowedDiscounts.Add(discount); return allowedDiscounts; } /// /// Gets allowed discounts applied to categories /// /// Product /// Customer /// /// A task that represents the asynchronous operation /// The task result contains the discounts /// protected virtual async Task> GetAllowedDiscountsAppliedToCategoriesAsync(Product product, Customer customer) { var allowedDiscounts = new List(); if (_catalogSettings.IgnoreDiscounts) return allowedDiscounts; //load cached discount models (performance optimization) foreach (var discount in await _discountService.GetAllDiscountsAsync(DiscountType.AssignedToCategories)) { //load identifier of categories with this discount applied to var discountCategoryIds = await _categoryService.GetAppliedCategoryIdsAsync(discount, customer); //compare with categories of this product var productCategoryIds = new List(); if (discountCategoryIds.Any()) { productCategoryIds = (await _categoryService .GetProductCategoriesByProductIdAsync(product.Id)) .Select(x => x.CategoryId) .ToList(); } var couponCodesToValidate = await _customerService.ParseAppliedDiscountCouponCodesAsync(customer); foreach (var categoryId in productCategoryIds) { if (!discountCategoryIds.Contains(categoryId)) continue; if (!_discountService.ContainsDiscount(allowedDiscounts, discount) && (await _discountService.ValidateDiscountAsync(discount, customer, couponCodesToValidate)).IsValid) allowedDiscounts.Add(discount); } } return allowedDiscounts; } /// /// Gets allowed discounts applied to manufacturers /// /// Product /// Customer /// /// A task that represents the asynchronous operation /// The task result contains the discounts /// protected virtual async Task> GetAllowedDiscountsAppliedToManufacturersAsync(Product product, Customer customer) { var allowedDiscounts = new List(); if (_catalogSettings.IgnoreDiscounts) return allowedDiscounts; foreach (var discount in await _discountService.GetAllDiscountsAsync(DiscountType.AssignedToManufacturers)) { //load identifier of manufacturers with this discount applied to var discountManufacturerIds = await _manufacturerService.GetAppliedManufacturerIdsAsync(discount, customer); //compare with manufacturers of this product var productManufacturerIds = new List(); if (discountManufacturerIds.Any()) { productManufacturerIds = (await _manufacturerService .GetProductManufacturersByProductIdAsync(product.Id)) .Select(x => x.ManufacturerId) .ToList(); } var couponCodesToValidate = await _customerService.ParseAppliedDiscountCouponCodesAsync(customer); foreach (var manufacturerId in productManufacturerIds) { if (!discountManufacturerIds.Contains(manufacturerId)) continue; if (!_discountService.ContainsDiscount(allowedDiscounts, discount) && (await _discountService.ValidateDiscountAsync(discount, customer, couponCodesToValidate)).IsValid) allowedDiscounts.Add(discount); } } return allowedDiscounts; } /// /// Gets allowed discounts /// /// Product /// Customer /// /// A task that represents the asynchronous operation /// The task result contains the discounts /// protected virtual async Task> GetAllowedDiscountsAsync(Product product, Customer customer) { var allowedDiscounts = new List(); if (_catalogSettings.IgnoreDiscounts) return allowedDiscounts; //discounts applied to products foreach (var discount in await GetAllowedDiscountsAppliedToProductAsync(product, customer)) if (!_discountService.ContainsDiscount(allowedDiscounts, discount)) allowedDiscounts.Add(discount); //discounts applied to categories foreach (var discount in await GetAllowedDiscountsAppliedToCategoriesAsync(product, customer)) if (!_discountService.ContainsDiscount(allowedDiscounts, discount)) allowedDiscounts.Add(discount); //discounts applied to manufacturers foreach (var discount in await GetAllowedDiscountsAppliedToManufacturersAsync(product, customer)) if (!_discountService.ContainsDiscount(allowedDiscounts, discount)) allowedDiscounts.Add(discount); return allowedDiscounts; } /// /// Gets discount amount /// /// Product /// The customer /// Already calculated product price without discount /// /// A task that represents the asynchronous operation /// The task result contains the discount amount, Applied discounts /// protected virtual async Task<(decimal, List)> GetDiscountAmountAsync(Product product, Customer customer, decimal productPriceWithoutDiscount) { ArgumentNullException.ThrowIfNull(product); var appliedDiscounts = new List(); var appliedDiscountAmount = decimal.Zero; //we don't apply discounts to products with price entered by a customer if (product.CustomerEntersPrice) return (appliedDiscountAmount, appliedDiscounts); //discounts are disabled if (_catalogSettings.IgnoreDiscounts) return (appliedDiscountAmount, appliedDiscounts); var allowedDiscounts = await GetAllowedDiscountsAsync(product, customer); //no discounts if (!allowedDiscounts.Any()) return (appliedDiscountAmount, appliedDiscounts); appliedDiscounts = _discountService.GetPreferredDiscount(allowedDiscounts, productPriceWithoutDiscount, out appliedDiscountAmount); return (appliedDiscountAmount, appliedDiscounts); } #endregion #region Methods /// /// Gets the final price /// /// Product /// The customer /// Store /// Additional charge /// A value indicating whether include discounts or not for final price computation /// Shopping cart item quantity /// /// A task that represents the asynchronous operation /// The task result contains the final price without discounts, Final price, Applied discount amount, Applied discounts /// public virtual async Task<(decimal priceWithoutDiscounts, decimal finalPrice, decimal appliedDiscountAmount, List appliedDiscounts)> GetFinalPriceAsync(Product product, Customer customer, Store store, decimal additionalCharge = 0, bool includeDiscounts = true, int quantity = 1) { return await GetFinalPriceAsync(product, customer, store, additionalCharge, includeDiscounts, quantity, null, null); } /// /// Gets the final price /// /// Product /// The customer /// Store /// Additional charge /// A value indicating whether include discounts or not for final price computation /// Shopping cart item quantity /// Rental period start date (for rental products) /// Rental period end date (for rental products) /// /// A task that represents the asynchronous operation /// The task result contains the final price without discounts, Final price, Applied discount amount, Applied discounts /// public virtual async Task<(decimal priceWithoutDiscounts, decimal finalPrice, decimal appliedDiscountAmount, List appliedDiscounts)> GetFinalPriceAsync(Product product, Customer customer, Store store, decimal additionalCharge, bool includeDiscounts, int quantity, DateTime? rentalStartDate, DateTime? rentalEndDate) { return await GetFinalPriceAsync(product, customer, store, null, additionalCharge, includeDiscounts, quantity, rentalStartDate, rentalEndDate); } /// /// Gets the final price /// /// Product /// The customer /// Store /// Overridden product price. If specified, then it'll be used instead of a product price. For example, used with product attribute combinations /// Additional charge /// A value indicating whether include discounts or not for final price computation /// Shopping cart item quantity /// Rental period start date (for rental products) /// Rental period end date (for rental products) /// /// A task that represents the asynchronous operation /// The task result contains the final price without discounts, Final price, Applied discount amount, Applied discounts /// public virtual async Task<(decimal priceWithoutDiscounts, decimal finalPrice, decimal appliedDiscountAmount, List appliedDiscounts)> GetFinalPriceAsync(Product product, Customer customer, Store store, decimal? overriddenProductPrice, decimal additionalCharge, bool includeDiscounts, int quantity, DateTime? rentalStartDate, DateTime? rentalEndDate) { ArgumentNullException.ThrowIfNull(product); var cacheKey = _staticCacheManager.PrepareKeyForDefaultCache(NopCatalogDefaults.ProductPriceCacheKey, product, overriddenProductPrice, additionalCharge, includeDiscounts, quantity, await _customerService.GetCustomerRoleIdsAsync(customer), store); //we do not cache price if this not allowed by settings or if the product is rental product //otherwise, it can cause memory leaks (to store all possible date period combinations) if (!_catalogSettings.CacheProductPrices || product.IsRental) cacheKey.CacheTime = 0; decimal rezPrice; decimal rezPriceWithoutDiscount; decimal discountAmount; List appliedDiscounts; (rezPriceWithoutDiscount, rezPrice, discountAmount, appliedDiscounts) = await _staticCacheManager.GetAsync(cacheKey, async () => { var discounts = new List(); var appliedDiscountAmount = decimal.Zero; //initial price var price = overriddenProductPrice ?? product.Price; //tier prices var tierPrice = await _productService.GetPreferredTierPriceAsync(product, customer, store, quantity); if (tierPrice != null) price = tierPrice.Price; //additional charge price += additionalCharge; //rental products if (product.IsRental) if (rentalStartDate.HasValue && rentalEndDate.HasValue) price *= _productService.GetRentalPeriods(product, rentalStartDate.Value, rentalEndDate.Value); var priceWithoutDiscount = price; if (includeDiscounts) { //discount var (tmpDiscountAmount, tmpAppliedDiscounts) = await GetDiscountAmountAsync(product, customer, price); price -= tmpDiscountAmount; if (tmpAppliedDiscounts?.Any() ?? false) { discounts.AddRange(tmpAppliedDiscounts); appliedDiscountAmount = tmpDiscountAmount; } } if (price < decimal.Zero) price = decimal.Zero; if (priceWithoutDiscount < decimal.Zero) priceWithoutDiscount = decimal.Zero; return (priceWithoutDiscount, price, appliedDiscountAmount, discounts); }); return (rezPriceWithoutDiscount, rezPrice, discountAmount, appliedDiscounts); } /// /// Gets the product cost (one item) /// /// Product /// Shopping cart item attributes in XML /// /// A task that represents the asynchronous operation /// The task result contains the product cost (one item) /// public virtual async Task GetProductCostAsync(Product product, string attributesXml) { ArgumentNullException.ThrowIfNull(product); var cost = product.ProductCost; var attributeValues = await _productAttributeParser.ParseProductAttributeValuesAsync(attributesXml); foreach (var attributeValue in attributeValues) { switch (attributeValue.AttributeValueType) { case AttributeValueType.Simple: //simple attribute cost += attributeValue.Cost; break; case AttributeValueType.AssociatedToProduct: //bundled product var associatedProduct = await _productService.GetProductByIdAsync(attributeValue.AssociatedProductId); if (associatedProduct != null) cost += associatedProduct.ProductCost * attributeValue.Quantity; break; default: break; } } return cost; } /// /// Get a price adjustment of a product attribute value /// /// Product /// Product attribute value /// Customer /// Store /// Product price (null for using the base product price) /// Shopping cart item quantity /// /// A task that represents the asynchronous operation /// The task result contains the price adjustment /// public virtual async Task GetProductAttributeValuePriceAdjustmentAsync(Product product, ProductAttributeValue value, Customer customer, Store store, decimal? productPrice = null, int quantity = 1) { ArgumentNullException.ThrowIfNull(value); var adjustment = decimal.Zero; switch (value.AttributeValueType) { case AttributeValueType.Simple: //simple attribute if (value.PriceAdjustmentUsePercentage) { if (!productPrice.HasValue) productPrice = (await GetFinalPriceAsync(product, customer, store, quantity: quantity)).finalPrice; adjustment = (decimal)((float)productPrice * (float)value.PriceAdjustment / 100f); } else { adjustment = value.PriceAdjustment; } break; case AttributeValueType.AssociatedToProduct: //bundled product var associatedProduct = await _productService.GetProductByIdAsync(value.AssociatedProductId); if (associatedProduct != null) adjustment = (await GetFinalPriceAsync(associatedProduct, customer, store)).finalPrice * value.Quantity; break; default: break; } return adjustment; } /// /// Round a product or order total for the currency /// /// Value to round /// Currency; pass null to use the primary store currency /// /// A task that represents the asynchronous operation /// The task result contains the rounded value /// public virtual async Task RoundPriceAsync(decimal value, Currency currency = null) { //we use this method because some currencies (e.g. Hungarian Forint or Swiss Franc) use non-standard rules for rounding //you can implement any rounding logic here currency ??= await _currencyService.GetCurrencyByIdAsync(_currencySettings.PrimaryStoreCurrencyId); return Round(value, currency.RoundingType); } /// /// Round /// /// Value to round /// The rounding type /// Rounded value public virtual decimal Round(decimal value, RoundingType roundingType) { //default round (Rounding001) var rez = Math.Round(value, 2); var fractionPart = (rez - Math.Truncate(rez)) * 10; //cash rounding not needed if (fractionPart == 0) return rez; //Cash rounding (details: https://en.wikipedia.org/wiki/Cash_rounding) switch (roundingType) { //rounding with 0.05 or 5 intervals case RoundingType.Rounding005Up: case RoundingType.Rounding005Down: fractionPart = (fractionPart - Math.Truncate(fractionPart)) * 10; fractionPart %= 5; if (fractionPart == 0) break; if (roundingType == RoundingType.Rounding005Up) fractionPart = 5 - fractionPart; else fractionPart *= -1; rez += fractionPart / 100; break; //rounding with 0.10 intervals case RoundingType.Rounding01Up: case RoundingType.Rounding01Down: fractionPart = (fractionPart - Math.Truncate(fractionPart)) * 10; if (roundingType == RoundingType.Rounding01Down && fractionPart == 5) fractionPart = -5; else fractionPart = fractionPart < 5 ? fractionPart * -1 : 10 - fractionPart; rez += fractionPart / 100; break; //rounding with 0.50 intervals case RoundingType.Rounding05: fractionPart *= 10; fractionPart = fractionPart < 25 ? fractionPart * -1 : fractionPart < 50 || fractionPart < 75 ? 50 - fractionPart : 100 - fractionPart; rez += fractionPart / 100; break; //rounding with 1.00 intervals case RoundingType.Rounding1: case RoundingType.Rounding1Up: fractionPart *= 10; if (roundingType == RoundingType.Rounding1Up && fractionPart > 0) rez = Math.Truncate(rez) + 1; else rez = fractionPart < 50 ? Math.Truncate(rez) : Math.Truncate(rez) + 1; break; case RoundingType.Rounding001: default: break; } return rez; } #endregion }