diff --git a/features/product/viewing_products/viewing_products_discounted_price.feature b/features/product/viewing_products/viewing_products_discounted_price.feature new file mode 100644 index 00000000000..b376dd7934b --- /dev/null +++ b/features/product/viewing_products/viewing_products_discounted_price.feature @@ -0,0 +1,16 @@ +@viewing_products +Feature: Viewing a product discounted price + In order to see products discounted price + As a Visitor + I want to be able to view a single product discounted price + + Background: + Given the store operates on a single channel in "United States" + + @ui + Scenario: Viewing a detailed page with product's original price + Given the store has a product "T-shirt banana" priced at "$39.00" + Given the product "T-shirt banana" has original price at "$50.00" + When I check this product's details + Then I should see the product price "$39.00" + Then I should see the product original price "$50.00" diff --git a/src/Sylius/Behat/Context/Setup/ProductContext.php b/src/Sylius/Behat/Context/Setup/ProductContext.php index 15d49685478..e17ad3df004 100644 --- a/src/Sylius/Behat/Context/Setup/ProductContext.php +++ b/src/Sylius/Behat/Context/Setup/ProductContext.php @@ -823,7 +823,7 @@ public function shortDescriptionOfProductIs(ProductInterface $product, string $s } /** - * @Given the product :product has original price :originalPrice + * @Given the product :product has original price at :originalPrice */ public function theProductHasOriginalPrice(ProductInterface $product, string $originalPrice): void { diff --git a/src/Sylius/Behat/Context/Ui/Shop/ProductContext.php b/src/Sylius/Behat/Context/Ui/Shop/ProductContext.php index 3f737e86eef..f59cc2fe6fb 100644 --- a/src/Sylius/Behat/Context/Ui/Shop/ProductContext.php +++ b/src/Sylius/Behat/Context/Ui/Shop/ProductContext.php @@ -300,6 +300,15 @@ public function iShouldSeeTheProductPrice($price) Assert::same($this->showPage->getPrice(), $price); } + /** + * @Then the product original price should be :price + * @Then I should see the product original price :price + */ + public function iShouldSeeTheProductOriginalPrice($price) + { + Assert::same($this->showPage->getOriginalPrice(), $price); + } + /** * @When I set its :optionName to :optionValue */ diff --git a/src/Sylius/Behat/Page/Shop/Product/ShowPage.php b/src/Sylius/Behat/Page/Shop/Product/ShowPage.php index 77f87b3c2ed..faed81160a3 100644 --- a/src/Sylius/Behat/Page/Shop/Product/ShowPage.php +++ b/src/Sylius/Behat/Page/Shop/Product/ShowPage.php @@ -128,6 +128,11 @@ public function getPrice(): string return $this->getElement('product_price')->getText(); } + public function getOriginalPrice(): string + { + return $this->getElement('product_original_price')->getText(); + } + public function hasAddToCartButton(): bool { if (!$this->hasElement('add_to_cart_button')) { @@ -261,6 +266,7 @@ protected function getDefinedElements(): array 'option_select' => '#sylius_add_to_cart_cartItem_variant_%optionCode%', 'out_of_stock' => '[data-test-product-out-of-stock]', 'product_price' => '[data-test-product-price]', + 'product_original_price' => '[data-test-product-original-price]', 'product_name' => '[data-test-product-name]', 'reviews' => '[data-test-product-reviews]', 'reviews_comment' => '[data-test-comment="%title%"]', diff --git a/src/Sylius/Behat/Page/Shop/Product/ShowPageInterface.php b/src/Sylius/Behat/Page/Shop/Product/ShowPageInterface.php index 010f286269f..60b690b5384 100644 --- a/src/Sylius/Behat/Page/Shop/Product/ShowPageInterface.php +++ b/src/Sylius/Behat/Page/Shop/Product/ShowPageInterface.php @@ -54,6 +54,8 @@ public function getName(): string; public function getPrice(): string; + public function getOriginalPrice(): string; + public function hasAddToCartButton(): bool; public function hasAssociation(string $productAssociationName): bool; diff --git a/src/Sylius/Bundle/CoreBundle/Templating/Helper/PriceHelper.php b/src/Sylius/Bundle/CoreBundle/Templating/Helper/PriceHelper.php index 6040ed35301..0464c67dd50 100644 --- a/src/Sylius/Bundle/CoreBundle/Templating/Helper/PriceHelper.php +++ b/src/Sylius/Bundle/CoreBundle/Templating/Helper/PriceHelper.php @@ -43,6 +43,32 @@ public function getPrice(ProductVariantInterface $productVariant, array $context ; } + /** + * {@inheritdoc} + * + * @throws \InvalidArgumentException + */ + public function getOriginalPrice(ProductVariantInterface $productVariant, array $context): int + { + Assert::keyExists($context, 'channel'); + + return $this + ->productVariantPriceCalculator + ->calculateOriginal($productVariant, $context); + } + + /** + * {@inheritdoc} + * + * @throws \InvalidArgumentException + */ + public function hasDiscount(ProductVariantInterface $productVariant, array $context): bool + { + Assert::keyExists($context, 'channel'); + + return $this->getOriginalPrice($productVariant, $context) > $this->getPrice($productVariant, $context); + } + /** * {@inheritdoc} */ diff --git a/src/Sylius/Bundle/CoreBundle/Twig/PriceExtension.php b/src/Sylius/Bundle/CoreBundle/Twig/PriceExtension.php index d89bf672b2e..5ae0a852758 100644 --- a/src/Sylius/Bundle/CoreBundle/Twig/PriceExtension.php +++ b/src/Sylius/Bundle/CoreBundle/Twig/PriceExtension.php @@ -34,6 +34,8 @@ public function getFilters(): array { return [ new TwigFilter('sylius_calculate_price', [$this->helper, 'getPrice']), + new TwigFilter('sylius_calculate_original_price', [$this->helper, 'getOriginalPrice']), + new TwigFilter('sylius_has_discount', [$this->helper, 'hasDiscount']), ]; } } diff --git a/src/Sylius/Bundle/CoreBundle/spec/Templating/Helper/PriceHelperSpec.php b/src/Sylius/Bundle/CoreBundle/spec/Templating/Helper/PriceHelperSpec.php index ab8041bb5b0..6aa55247529 100644 --- a/src/Sylius/Bundle/CoreBundle/spec/Templating/Helper/PriceHelperSpec.php +++ b/src/Sylius/Bundle/CoreBundle/spec/Templating/Helper/PriceHelperSpec.php @@ -54,6 +54,67 @@ function it_throws_invalid_argument_exception_when_channel_key_is_not_present_in $productVariantPriceCalculator->calculate($productVariant, $context)->shouldNotBeCalled(); } + function it_returns_variant_original_price_for_channel_given_in_context( + ChannelInterface $channel, + ProductVariantInterface $productVariant, + ProductVariantPriceCalculatorInterface $productVariantPriceCalculator + ): void { + $context = ['channel' => $channel]; + + $productVariantPriceCalculator->calculateOriginal($productVariant, $context)->willReturn(1000); + + $this->getOriginalPrice($productVariant, $context)->shouldReturn(1000); + } + + function it_throws_invalid_argument_exception_when_channel_key_is_not_present_in_context_when_getting_original_price( + ProductVariantInterface $productVariant, + ProductVariantPriceCalculatorInterface $productVariantPriceCalculator + ): void { + $context = ['lennahc' => '']; + + $this->shouldThrow(\InvalidArgumentException::class)->during('getOriginalPrice', [$productVariant, $context]); + + $productVariantPriceCalculator->calculateOriginal($productVariant, $context)->shouldNotBeCalled(); + } + + function it_returns_true_if_variant_is_discounted_for_channel_given_in_context( + ChannelInterface $channel, + ProductVariantInterface $productVariant, + ProductVariantPriceCalculatorInterface $productVariantPriceCalculator + ): void { + $context = ['channel' => $channel]; + + $productVariantPriceCalculator->calculate($productVariant, $context)->willReturn(950); + $productVariantPriceCalculator->calculateOriginal($productVariant, $context)->willReturn(1000); + + $this->hasDiscount($productVariant, $context)->shouldReturn(true); + } + + function it_returns_false_if_variant_is_not_discounted_for_channel_given_in_context( + ChannelInterface $channel, + ProductVariantInterface $productVariant, + ProductVariantPriceCalculatorInterface $productVariantPriceCalculator + ): void { + $context = ['channel' => $channel]; + + $productVariantPriceCalculator->calculate($productVariant, $context)->willReturn(1000); + $productVariantPriceCalculator->calculateOriginal($productVariant, $context)->willReturn(1000); + + $this->hasDiscount($productVariant, $context)->shouldReturn(false); + } + + function it_throws_invalid_argument_exception_when_channel_key_is_not_present_in_context_when_checking_if_variant_is_discounted( + ProductVariantInterface $productVariant, + ProductVariantPriceCalculatorInterface $productVariantPriceCalculator + ): void { + $context = ['lennahc' => '']; + + $this->shouldThrow(\InvalidArgumentException::class)->during('hasDiscount', [$productVariant, $context]); + + $productVariantPriceCalculator->calculate($productVariant, $context)->shouldNotBeCalled(); + $productVariantPriceCalculator->calculateOriginal($productVariant, $context)->shouldNotBeCalled(); + } + function it_has_name(): void { $this->getName()->shouldReturn('sylius_calculate_price'); diff --git a/src/Sylius/Bundle/ShopBundle/Resources/views/Common/Macro/money.html.twig b/src/Sylius/Bundle/ShopBundle/Resources/views/Common/Macro/money.html.twig index 1c4eafb5153..23f22ff01ee 100644 --- a/src/Sylius/Bundle/ShopBundle/Resources/views/Common/Macro/money.html.twig +++ b/src/Sylius/Bundle/ShopBundle/Resources/views/Common/Macro/money.html.twig @@ -13,3 +13,9 @@ {{- convertAndFormat(variant|sylius_calculate_price({'channel': sylius.channel})) }} {%- endmacro -%} + +{%- macro calculateOriginalPrice(variant) -%} + {% from _self import convertAndFormat %} + + {{- convertAndFormat(variant|sylius_calculate_original_price({'channel': sylius.channel})) }} +{%- endmacro -%} diff --git a/src/Sylius/Bundle/ShopBundle/Resources/views/Product/Show/_price.html.twig b/src/Sylius/Bundle/ShopBundle/Resources/views/Product/Show/_price.html.twig index ea8ad3a3fe7..56d9dd044d8 100644 --- a/src/Sylius/Bundle/ShopBundle/Resources/views/Product/Show/_price.html.twig +++ b/src/Sylius/Bundle/ShopBundle/Resources/views/Product/Show/_price.html.twig @@ -1,5 +1,14 @@ {% import "@SyliusShop/Common/Macro/money.html.twig" as money %} - - {{ money.calculatePrice(product|sylius_resolve_variant) }} +{% set variant = product|sylius_resolve_variant %} +{% set hasDiscount = variant|sylius_has_discount({'channel': sylius.channel}) %} + +{% if hasDiscount %} + + {{ money.calculateOriginalPrice(variant) }} + +{% endif %} + + + {{ money.calculatePrice(variant) }} diff --git a/src/Sylius/Component/Core/Calculator/ProductVariantPriceCalculator.php b/src/Sylius/Component/Core/Calculator/ProductVariantPriceCalculator.php index 79854e6d369..f25608ae7f0 100644 --- a/src/Sylius/Component/Core/Calculator/ProductVariantPriceCalculator.php +++ b/src/Sylius/Component/Core/Calculator/ProductVariantPriceCalculator.php @@ -42,4 +42,30 @@ public function calculate(ProductVariantInterface $productVariant, array $contex return $channelPricing->getPrice(); } + + /** + * {@inheritdoc} + * + * @throws \InvalidArgumentException|MissingChannelConfigurationException + */ + public function calculateOriginal(ProductVariantInterface $productVariant, array $context): int + { + Assert::keyExists($context, 'channel'); + + $channelPricing = $productVariant->getChannelPricingForChannel($context['channel']); + + if (null === $channelPricing) { + throw new MissingChannelConfigurationException(sprintf( + 'Channel %s has no price defined for product variant %s', + $context['channel']->getName(), + $productVariant->getName() + )); + } + + if (null === $channelPricing->getOriginalPrice()) { + return $channelPricing->getPrice(); + } + + return $channelPricing->getOriginalPrice(); + } } diff --git a/src/Sylius/Component/Core/Calculator/ProductVariantPriceCalculatorInterface.php b/src/Sylius/Component/Core/Calculator/ProductVariantPriceCalculatorInterface.php index e3b54d9c1d7..0629cb22f86 100644 --- a/src/Sylius/Component/Core/Calculator/ProductVariantPriceCalculatorInterface.php +++ b/src/Sylius/Component/Core/Calculator/ProductVariantPriceCalculatorInterface.php @@ -22,4 +22,9 @@ interface ProductVariantPriceCalculatorInterface * @throws MissingChannelConfigurationException when price for given channel does not exist */ public function calculate(ProductVariantInterface $productVariant, array $context): int; + + /** + * @throws MissingChannelConfigurationException when price for given channel does not exist + */ + public function calculateOriginal(ProductVariantInterface $productVariant, array $context): int; } diff --git a/src/Sylius/Component/Core/spec/Calculator/ProductVariantPriceCalculatorSpec.php b/src/Sylius/Component/Core/spec/Calculator/ProductVariantPriceCalculatorSpec.php index 13ffca75ead..85b85d7665b 100644 --- a/src/Sylius/Component/Core/spec/Calculator/ProductVariantPriceCalculatorSpec.php +++ b/src/Sylius/Component/Core/spec/Calculator/ProductVariantPriceCalculatorSpec.php @@ -61,4 +61,51 @@ function it_throws_exception_if_no_channel_is_defined_in_configuration(ProductVa ->during('calculate', [$productVariant, []]) ; } + + function it_gets_original_price_for_product_variant_in_given_channel( + ChannelInterface $channel, + ChannelPricingInterface $channelPricing, + ProductVariantInterface $productVariant + ): void { + $productVariant->getChannelPricingForChannel($channel)->willReturn($channelPricing); + $channelPricing->getOriginalPrice()->willReturn(1000); + + $this->calculateOriginal($productVariant, ['channel' => $channel])->shouldReturn(1000); + } + + function it_gets_price_for_product_variant_if_it_has_no_original_price_in_given_channel( + ChannelInterface $channel, + ChannelPricingInterface $channelPricing, + ProductVariantInterface $productVariant + ): void { + $productVariant->getChannelPricingForChannel($channel)->willReturn($channelPricing); + $channelPricing->getPrice()->willReturn(1000); + $channelPricing->getOriginalPrice()->willReturn(null); + + $this->calculateOriginal($productVariant, ['channel' => $channel])->shouldReturn(1000); + } + + function it_throws_a_channel_not_defined_exception_if_there_is_no_variant_price_for_given_channel_when_calculating_original_price( + ChannelInterface $channel, + ProductVariantInterface $productVariant + ): void { + $channel->getName()->willReturn('WEB'); + + $productVariant->getChannelPricingForChannel($channel)->willReturn(null); + $productVariant->getName()->willReturn('Red variant'); + $productVariant->getCode()->willReturn('RED_VARIANT'); + + $this + ->shouldThrow(MissingChannelConfigurationException::class) + ->during('calculateOriginal', [$productVariant, ['channel' => $channel]]) + ; + } + + function it_throws_exception_if_no_channel_is_defined_in_configuration_when_calculating_original_price(ProductVariantInterface $productVariant): void + { + $this + ->shouldThrow(\InvalidArgumentException::class) + ->during('calculateOriginal', [$productVariant, []]) + ; + } }