* @copyright Since 2007 PrestaShop SA and Contributors * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) */ declare(strict_types=1); namespace PrestaShop\PrestaShop\Adapter\Order; use Address; use Cache; use Carrier; use Cart; use CartRule; use Currency; use Customer; use Order; use OrderCarrier; use OrderCartRule; use OrderDetail; use PrestaShop\Decimal\Number; use PrestaShop\PrestaShop\Adapter\Cart\Comparator\CartProductsComparator; use PrestaShop\PrestaShop\Adapter\Cart\Comparator\CartProductUpdate; use PrestaShop\PrestaShop\Adapter\ContextStateManager; use PrestaShop\PrestaShop\Adapter\Order\Refund\OrderProductRemover; use PrestaShop\PrestaShop\Core\Cart\CartRuleData; use PrestaShop\PrestaShop\Core\Domain\Configuration\ShopConfigurationInterface; use PrestaShop\PrestaShop\Core\Domain\Order\Exception\OrderException; use PrestaShop\PrestaShop\Core\Domain\Shop\ValueObject\ShopConstraint; use PrestaShop\PrestaShop\Core\Localization\CLDR\ComputingPrecision; use PrestaShopDatabaseException; use PrestaShopException; use Product; use Shop; use Tools; use Validate; class OrderAmountUpdater { /** * @var ShopConfigurationInterface */ private $shopConfiguration; /** * @var ContextStateManager */ private $contextStateManager; /** * @var OrderDetailUpdater */ private $orderDetailUpdater; /** * @var OrderProductRemover */ private $orderProductRemover; /** * @var array */ private $orderConstraints = []; /** * @var bool */ private $keepOrderPrices = true; /** * @param ShopConfigurationInterface $shopConfiguration * @param ContextStateManager $contextStateManager * @param OrderDetailUpdater $orderDetailUpdater * @param OrderProductRemover $orderProductRemover */ public function __construct( ShopConfigurationInterface $shopConfiguration, ContextStateManager $contextStateManager, OrderDetailUpdater $orderDetailUpdater, OrderProductRemover $orderProductRemover ) { $this->shopConfiguration = $shopConfiguration; $this->contextStateManager = $contextStateManager; $this->orderDetailUpdater = $orderDetailUpdater; $this->orderProductRemover = $orderProductRemover; } /** * @param Order $order * @param Cart $cart * @param int|null $orderInvoiceId * * @throws OrderException * @throws PrestaShopDatabaseException * @throws PrestaShopException */ public function update( Order $order, Cart $cart, ?int $orderInvoiceId = null ): void { $this->cleanCaches(); $this->contextStateManager ->saveCurrentContext() ->setCart($cart) ->setCurrency(new Currency($cart->id_currency)) ->setCustomer(new Customer($cart->id_customer)) ->setLanguage($cart->getAssociatedLanguage()) ->setCountry($cart->getTaxCountry()) ->setShop(new Shop($cart->id_shop)) ; try { // @todo: use https://github.com/PrestaShop/decimal for price computations $computingPrecision = $this->getPrecisionFromCart($cart); // Update order details (if quantity or product price have been modified) $this->updateOrderDetails($order, $cart); $productsComparator = new CartProductsComparator($cart); // Recalculate cart rules and Fix differences between cart's cartRules and order's cartRules $this->updateOrderCartRules($order, $cart, $computingPrecision, $orderInvoiceId); // Synchronize modified products with the order $modifiedProducts = $productsComparator->getModifiedProducts(); $this->updateOrderModifiedProducts($modifiedProducts, $cart, $order); // Update order totals $this->updateOrderTotals($order, $cart, $computingPrecision); // Update carrier weight for shipping cost $this->updateOrderCarrier($order, $cart); // Order::update is called after previous functions so that we only call it once if (!$order->update()) { throw new OrderException('Could not update order invoice in database.'); } $this->updateOrderInvoices($order, $cart, $computingPrecision); } finally { $this->contextStateManager->restorePreviousContext(); } } /** * Synchronizes modified products from the cart with the order * * @param CartProductUpdate[] $modifiedProducts * @param Cart $cart * @param Order $order * * @throws OrderException * @throws PrestaShopDatabaseException * @throws PrestaShopException */ private function updateOrderModifiedProducts(array $modifiedProducts, Cart $cart, Order $order): void { $productsToAddToOrder = []; foreach ($modifiedProducts as $modifiedProduct) { $orderProduct = $this->findProductInOrder($modifiedProduct, $order); $cartProduct = $this->findProductInCart($modifiedProduct, $cart); if (null === $cartProduct) { // The product is not in the cart anymore: delete it from the order $orderDetail = new OrderDetail($orderProduct['id_order_detail']); $this->orderProductRemover->deleteProductFromOrder($order, $orderDetail, false); } elseif (null === $orderProduct) { // The product is not in the order but in the cart: add it to the order $productsToAddToOrder[] = $cartProduct; } else { // the product is both in the cart and the order: update its quantity and price with the cart values $orderDetail = new OrderDetail($orderProduct['id_order_detail']); $orderDetail->product_quantity = $cartProduct['cart_quantity']; $this->orderDetailUpdater->updateOrderDetail( $orderDetail, $order, new Number((string) $cartProduct['price_with_reduction_without_tax']), new Number((string) $cartProduct['price_with_reduction']) ); } } if (count($productsToAddToOrder) > 0) { $orderDetail = new OrderDetail(); $orderDetail->createList($order, $cart, $order->getCurrentState(), $productsToAddToOrder); } } /** * @param CartProductUpdate $productUpdate * @param Cart $cart * * @return array|null */ private function findProductInCart(CartProductUpdate $productUpdate, Cart $cart): ?array { $combinationId = null === $productUpdate->getCombinationId() ? 0 : $productUpdate->getCombinationId()->getValue(); foreach ($cart->getProducts() as $product) { if ((int) $product['id_product'] === $productUpdate->getProductId()->getValue() && (int) $product['id_product_attribute'] === $combinationId) { return $product; } } return null; } /** * @param CartProductUpdate $productUpdate * @param Order $order * * @return array|null */ private function findProductInOrder(CartProductUpdate $productUpdate, Order $order): ?array { $combinationId = null === $productUpdate->getCombinationId() ? 0 : $productUpdate->getCombinationId()->getValue(); foreach ($order->getProducts() as $product) { if ((int) $product['product_id'] === $productUpdate->getProductId()->getValue() && (int) $product['product_attribute_id'] === $combinationId) { return $product; } } return null; } /** * There are many caches among legacy classes that can store previous prices * we need to clean them to make sure the price is completely up to date */ private function cleanCaches(): void { // For many intermediate computations Cart::resetStaticCache(); // For discount computation CartRule::resetStaticCache(); Cache::clean('getContextualValue_*'); // For shipping costs Carrier::resetStaticCache(); Cache::clean('getPackageShippingCost_*'); } /** * @param Order $order * @param Cart $cart * @param int $computingPrecision */ private function updateOrderTotals(Order $order, Cart $cart, int $computingPrecision): void { $orderProducts = $order->getCartProducts(); $carrierId = $order->id_carrier; $order->total_discounts = (float) abs($cart->getOrderTotal(true, Cart::ONLY_DISCOUNTS, $orderProducts, $carrierId, false, $this->keepOrderPrices)); $order->total_discounts_tax_excl = (float) abs($cart->getOrderTotal(false, Cart::ONLY_DISCOUNTS, $orderProducts, $carrierId, false, $this->keepOrderPrices)); $order->total_discounts_tax_incl = (float) abs($cart->getOrderTotal(true, Cart::ONLY_DISCOUNTS, $orderProducts, $carrierId, false, $this->keepOrderPrices)); // We should always use Cart::BOTH for the order total since it contains all products, shipping fees and cart rules $order->total_paid = Tools::ps_round( (float) $cart->getOrderTotal(true, Cart::BOTH, $orderProducts, $carrierId, false, $this->keepOrderPrices), $computingPrecision ); $order->total_paid_tax_excl = Tools::ps_round( (float) $cart->getOrderTotal(false, Cart::BOTH, $orderProducts, $carrierId, false, $this->keepOrderPrices), $computingPrecision ); $order->total_paid_tax_incl = Tools::ps_round( (float) $cart->getOrderTotal(true, Cart::BOTH, $orderProducts, $carrierId, false, $this->keepOrderPrices), $computingPrecision ); $order->total_products = (float) $cart->getOrderTotal(false, Cart::ONLY_PRODUCTS, $orderProducts, $carrierId, false, $this->keepOrderPrices); $order->total_products_wt = (float) $cart->getOrderTotal(true, Cart::ONLY_PRODUCTS, $orderProducts, $carrierId, false, $this->keepOrderPrices); $order->total_wrapping = abs($cart->getOrderTotal(true, Cart::ONLY_WRAPPING, $orderProducts, $carrierId, false, $this->keepOrderPrices)); $order->total_wrapping_tax_excl = abs($cart->getOrderTotal(false, Cart::ONLY_WRAPPING, $orderProducts, $carrierId, false, $this->keepOrderPrices)); $order->total_wrapping_tax_incl = abs($cart->getOrderTotal(true, Cart::ONLY_WRAPPING, $orderProducts, $carrierId, false, $this->keepOrderPrices)); $totalShippingTaxIncluded = $order->total_shipping_tax_incl; $totalShippingTaxExcluded = $order->total_shipping_tax_excl; $order->total_shipping = $cart->getOrderTotal(true, Cart::ONLY_SHIPPING, $orderProducts, $carrierId, false, $this->keepOrderPrices); $order->total_shipping_tax_excl = $cart->getOrderTotal(false, Cart::ONLY_SHIPPING, $orderProducts, $carrierId, false, $this->keepOrderPrices); $order->total_shipping_tax_incl = $cart->getOrderTotal(true, Cart::ONLY_SHIPPING, $orderProducts, $carrierId, false, $this->keepOrderPrices); if (!$this->getOrderConfiguration('PS_ORDER_RECALCULATE_SHIPPING', $order)) { $freeShipping = $this->isFreeShipping($order); if ($freeShipping) { $order->total_discounts = $order->total_discounts - $order->total_shipping_tax_incl + $totalShippingTaxIncluded; $order->total_discounts_tax_excl = $order->total_discounts_tax_excl - $order->total_shipping_tax_excl + $totalShippingTaxExcluded; $order->total_discounts_tax_incl = $order->total_discounts_tax_incl - $order->total_shipping_tax_incl + $totalShippingTaxIncluded; } else { $order->total_paid -= ($order->total_shipping_tax_incl - $totalShippingTaxIncluded); $order->total_paid_tax_incl -= ($order->total_shipping_tax_incl - $totalShippingTaxIncluded); $order->total_paid_tax_excl -= ($order->total_shipping_tax_excl - $totalShippingTaxExcluded); } $order->total_shipping = $totalShippingTaxIncluded; $order->total_shipping_tax_incl = $totalShippingTaxIncluded; $order->total_shipping_tax_excl = $totalShippingTaxExcluded; } } /** * @param Order $order * @param Cart $cart * * @throws PrestaShopDatabaseException * @throws PrestaShopException */ private function updateOrderCarrier(Order $order, Cart $cart): void { $orderCarrier = new OrderCarrier((int) $order->getIdOrderCarrier()); if (Validate::isLoadedObject($orderCarrier)) { $orderCarrier->weight = (float) $order->getTotalWeight(); $orderCarrier->shipping_cost_tax_incl = (float) $order->total_shipping_tax_incl; $orderCarrier->shipping_cost_tax_excl = (float) $order->total_shipping_tax_excl; if ($orderCarrier->update()) { $order->weight = sprintf('%.3f ' . $this->getOrderConfiguration('PS_WEIGHT_UNIT', $order), $orderCarrier->weight); } } if (!$cart->isVirtualCart() && !empty($order->id_carrier)) { $carrier = new Carrier((int) $order->id_carrier, (int) $cart->id_lang); if (Validate::isLoadedObject($carrier)) { $taxAddressId = (int) $order->{$this->getOrderConfiguration('PS_TAX_ADDRESS_TYPE', $order)}; $order->carrier_tax_rate = $carrier->getTaxesRate(new Address($taxAddressId)); } } } /** * @param Order $order * @param Cart $cart * * @throws OrderException * @throws PrestaShopDatabaseException * @throws PrestaShopException */ private function updateOrderDetails(Order $order, Cart $cart): void { // Get cart products with prices kept from order $cartProducts = $cart->getProducts(true, false, null, true, $this->keepOrderPrices); foreach ($order->getCartProducts() as $orderProduct) { $orderDetail = new OrderDetail($orderProduct['id_order_detail'], null, $this->contextStateManager->getContext()); $cartProduct = $this->getProductFromCart($cartProducts, (int) $orderDetail->product_id, (int) $orderDetail->product_attribute_id); $this->orderDetailUpdater->updateOrderDetail( $orderDetail, $order, new Number((string) $cartProduct['price_with_reduction_without_tax']), new Number((string) $cartProduct['price_with_reduction']) ); } } /** * @param array $cartProducts * @param int $productId * @param int $productAttributeId * * @return array */ private function getProductFromCart(array $cartProducts, int $productId, int $productAttributeId): array { $cartProduct = array_reduce($cartProducts, function ($carry, $item) use ($productId, $productAttributeId) { if (null !== $carry) { return $carry; } $productMatch = $item['id_product'] == $productId; $combinationMatch = $item['id_product_attribute'] == $productAttributeId; return $productMatch && $combinationMatch ? $item : null; }); // This shouldn't happen, if it does something was not done before updating the Order (removing an OrderDetail maybe) if (null === $cartProduct) { throw new OrderException('Could not find the product in cart, meaning Order and Cart are out of sync'); } return $cartProduct; } /** * Update cart rules to be synced with current cart: * - cart rules attached to new product may be added/removed * - global shop cart rules may be added/removed * - cart rules amount may vary because other cart rules have been added/removed * * @param Order $order * @param Cart $cart * @param int $computingPrecision * @param int|null $orderInvoiceId * * @throws OrderException * @throws PrestaShopDatabaseException * @throws PrestaShopException */ private function updateOrderCartRules( Order $order, Cart $cart, int $computingPrecision, ?int $orderInvoiceId ): void { CartRule::autoAddToCart(null, true); CartRule::autoRemoveFromCart(null, true); $carrierId = $order->id_carrier; $newCartRules = $cart->getCartRules(CartRule::FILTER_ACTION_ALL, false); // We need the calculator to compute the discount on the whole products because they can interact with each // other so they can't be computed independently, it needs to keep order prices $calculator = $cart->newCalculator($cart->getProducts(), $newCartRules, $carrierId, $computingPrecision, $this->keepOrderPrices); $calculator->processCalculation(); foreach ($order->getCartRules() as $orderCartRuleData) { /** @var CartRuleData $cartRuleData */ foreach ($calculator->getCartRulesData() as $cartRuleData) { $cartRule = $cartRuleData->getCartRule(); if ($cartRule->id == $orderCartRuleData['id_cart_rule']) { // Cart rule is still in the cart no need to remove it, but we update it as the amount may have changed $orderCartRule = new OrderCartRule($orderCartRuleData['id_order_cart_rule']); $orderCartRule->id_order = $order->id; $orderCartRule->name = $cartRule->name; $orderCartRule->free_shipping = $cartRule->free_shipping; $orderCartRule->value = Tools::ps_round($cartRuleData->getDiscountApplied()->getTaxIncluded(), $computingPrecision); $orderCartRule->value_tax_excl = Tools::ps_round($cartRuleData->getDiscountApplied()->getTaxExcluded(), $computingPrecision); if ($orderCartRule->free_shipping && !$this->getOrderConfiguration('PS_ORDER_RECALCULATE_SHIPPING', $order)) { $orderCartRule->value = $orderCartRule->value - $calculator->getFees()->getInitialShippingFees()->getTaxIncluded() + $order->total_shipping; $orderCartRule->value_tax_excl = $orderCartRule->value_tax_excl - $calculator->getFees()->getInitialShippingFees()->getTaxExcluded() + $order->total_shipping_tax_excl; } $orderCartRule->save(); continue 2; } } // This one is no longer in the new cart rules so we delete it $orderCartRule = new OrderCartRule($orderCartRuleData['id_order_cart_rule']); // This one really needs to be deleted because it doesn't match the applied cart rules any more // we don't use soft deleted here (unlike in the handler) but hard delete if (!$orderCartRule->delete()) { throw new OrderException('Could not delete order cart rule from database.'); } } // Finally add the new cart rules that are not in the Order foreach ($calculator->getCartRulesData() as $cartRuleData) { $cartRule = $cartRuleData->getCartRule(); foreach ($order->getCartRules() as $orderCartRuleData) { if ($cartRule->id == $orderCartRuleData['id_cart_rule']) { // This cart rule is already present no need to add it continue 2; } } // Add missing order cart rule $orderCartRule = new OrderCartRule(); $orderCartRule->id_order = $order->id; $orderCartRule->id_cart_rule = $cartRule->id; $orderCartRule->id_order_invoice = $orderInvoiceId ?? 0; $orderCartRule->name = $cartRule->name; $orderCartRule->free_shipping = $cartRule->free_shipping; $orderCartRule->value = Tools::ps_round($cartRuleData->getDiscountApplied()->getTaxIncluded(), $computingPrecision); $orderCartRule->value_tax_excl = Tools::ps_round($cartRuleData->getDiscountApplied()->getTaxExcluded(), $computingPrecision); $orderCartRule->save(); } } /** * @param Order $order * @param Cart $cart * @param int $computingPrecision * * @throws OrderException * @throws PrestaShopDatabaseException * @throws PrestaShopException */ private function updateOrderInvoices(Order $order, Cart $cart, int $computingPrecision): void { $invoiceProducts = []; foreach ($order->getCartProducts() as $orderProduct) { if (!empty($orderProduct['id_order_invoice'])) { $invoiceProducts[$orderProduct['id_order_invoice']][] = $orderProduct; } } $invoiceCollection = $order->getInvoicesCollection(); $firstInvoice = $invoiceCollection->getFirst(); foreach ($invoiceCollection as $invoice) { // If all the invoice's products have been removed the offset won't exist $currentInvoiceProducts = isset($invoiceProducts[$invoice->id]) ? $invoiceProducts[$invoice->id] : []; // Shipping are computed on first invoice only $carrierId = $order->id_carrier; $totalMethod = ($firstInvoice === false || $firstInvoice->id == $invoice->id) ? Cart::BOTH : Cart::BOTH_WITHOUT_SHIPPING; $invoice->total_paid_tax_excl = Tools::ps_round( (float) $cart->getOrderTotal(false, $totalMethod, $currentInvoiceProducts, $carrierId, false, $this->keepOrderPrices), $computingPrecision ); $invoice->total_paid_tax_incl = Tools::ps_round( (float) $cart->getOrderTotal(true, $totalMethod, $currentInvoiceProducts, $carrierId, false, $this->keepOrderPrices), $computingPrecision ); $invoice->total_products = Tools::ps_round( (float) $cart->getOrderTotal(false, Cart::ONLY_PRODUCTS, $currentInvoiceProducts, $carrierId, false, $this->keepOrderPrices), $computingPrecision ); $invoice->total_products_wt = Tools::ps_round( (float) $cart->getOrderTotal(true, Cart::ONLY_PRODUCTS, $currentInvoiceProducts, $carrierId, false, $this->keepOrderPrices), $computingPrecision ); $invoice->total_discount_tax_excl = Tools::ps_round( (float) $cart->getOrderTotal(false, Cart::ONLY_DISCOUNTS, $currentInvoiceProducts, $carrierId, false, $this->keepOrderPrices), $computingPrecision ); $invoice->total_discount_tax_incl = Tools::ps_round( (float) $cart->getOrderTotal(true, Cart::ONLY_DISCOUNTS, $currentInvoiceProducts, $carrierId, false, $this->keepOrderPrices), $computingPrecision ); $totalShippingTaxIncluded = $invoice->total_shipping_tax_incl; $totalShippingTaxExcluded = $invoice->total_shipping_tax_excl; $invoice->total_shipping = $cart->getOrderTotal(true, Cart::ONLY_SHIPPING, $currentInvoiceProducts, $carrierId, false, $this->keepOrderPrices); $invoice->total_shipping_tax_excl = $cart->getOrderTotal(false, Cart::ONLY_SHIPPING, $currentInvoiceProducts, $carrierId, false, $this->keepOrderPrices); $invoice->total_shipping_tax_incl = $cart->getOrderTotal(true, Cart::ONLY_SHIPPING, $currentInvoiceProducts, $carrierId, false, $this->keepOrderPrices); if (!$this->getOrderConfiguration('PS_ORDER_RECALCULATE_SHIPPING', $order)) { $freeShipping = $this->isFreeShipping($order); if ($freeShipping) { $invoice->total_discount_tax_excl = $invoice->total_discount_tax_excl - $invoice->total_shipping_tax_excl + $totalShippingTaxExcluded; $invoice->total_discount_tax_incl = $invoice->total_discount_tax_incl - $invoice->total_shipping_tax_incl + $totalShippingTaxIncluded; } else { $invoice->total_paid_tax_incl -= ($invoice->total_shipping_tax_incl - $totalShippingTaxIncluded); $invoice->total_paid_tax_excl -= ($invoice->total_shipping_tax_excl - $totalShippingTaxExcluded); } $invoice->total_shipping_tax_incl = $totalShippingTaxIncluded; $invoice->total_shipping_tax_excl = $totalShippingTaxExcluded; } if (!$invoice->update()) { throw new OrderException('Could not update order invoice in database.'); } } } /** * @param Order $order * * @return bool */ protected function isFreeShipping(Order $order): bool { foreach ($order->getCartRules() as $cartRule) { if ($cartRule['free_shipping']) { return true; } } return false; } /** * @param Cart $cart * * @return int */ private function getPrecisionFromCart(Cart $cart): int { $computingPrecision = new ComputingPrecision(); $currency = new Currency((int) $cart->id_currency); return $computingPrecision->getPrecision((int) $currency->precision); } /** * @param string $key * @param Order $order * * @return mixed */ private function getOrderConfiguration(string $key, Order $order) { return $this->shopConfiguration->get($key, null, $this->getOrderShopConstraint($order)); } /** * @param Order $order * * @return ShopConstraint */ private function getOrderShopConstraint(Order $order): ShopConstraint { $constraintKey = $order->id_shop . '-' . $order->id_shop_group; if (!isset($this->orderConstraints[$constraintKey])) { $this->orderConstraints[$constraintKey] = ShopConstraint::shop((int) $order->id_shop); } return $this->orderConstraints[$constraintKey]; } }