[Shop] Improved implementation of promotion integrity checker

This commit is contained in:
Łukasz Chruściel 2019-08-21 15:24:33 +02:00
parent a93b2558e5
commit e73a517e44
No known key found for this signature in database
GPG key ID: 428B9D810DB2ACDF
11 changed files with 204 additions and 297 deletions

View file

@ -24,7 +24,7 @@ Feature: Order promotions integrity
And I should not see the thank you page
@ui
Scenario: Basket with several promotions is able to checkout
Scenario: Being able to completing checkout with several promotions
And this promotion gives "12%" discount to every order
And there is a promotion "New Year" with priority 2
And the promotion gives "$10.00" discount to every order with items total at least "$100.00"
@ -49,11 +49,11 @@ Feature: Order promotions integrity
Then I should see the thank you page
@ui
Scenario: Removing promotion adjustment recalculate the tax on the order item unit
Scenario: Excluded tax is not taken into account into promotion integrity check
Given the store has "VAT" tax rate of 20% for "Clothes" within the "US" zone
And this product belongs to "Clothes" tax category
And this promotion gives "50%" discount to every order
And I added product "PHP T-Shirt" to the cart
When I proceed selecting "Offline" payment method
And I confirm my order
And I should see the thank you page
Then I should see the thank you page

View file

@ -186,9 +186,9 @@ final class CheckoutCompleteContext implements Context
/**
* @Given my tax total should be :taxTotal
*/
public function myTaxTotalShouldBe($taxTotal)
public function myTaxTotalShouldBe(string $taxTotal): void
{
Assert::true($this->completePage->hasTaxTotal($taxTotal));
Assert::same($this->completePage->getTaxTotal(), $taxTotal);
}
/**

View file

@ -177,12 +177,9 @@ class CompletePage extends SymfonyPage implements CompletePageInterface
return false !== stripos($this->getElement('promotion_shipping_discounts')->getText(), $promotionName);
}
/**
* {@inheritdoc}
*/
public function hasTaxTotal($taxTotal)
public function getTaxTotal(): string
{
return false !== strpos($this->getElement('tax_total')->getText(), $taxTotal);
return str_replace('Tax total: ', '', $this->getElement('tax_total')->getText());
}
/**

View file

@ -93,12 +93,7 @@ interface CompletePageInterface extends SymfonyPageInterface
*/
public function hasShippingPromotion($promotionName);
/**
* @param string $taxTotal
*
* @return bool
*/
public function hasTaxTotal($taxTotal);
public function getTaxTotal(): string;
/**
* @param string $price

View file

@ -13,32 +13,34 @@ declare(strict_types=1);
namespace Sylius\Bundle\ShopBundle\EventListener;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Persistence\ObjectManager;
use Sylius\Bundle\ResourceBundle\Event\ResourceControllerEvent;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\PromotionInterface;
use Sylius\Component\Order\Processor\OrderProcessorInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\RouterInterface;
use Webmozart\Assert\Assert;
final class OrderTotalIntegrityChecker
final class OrderIntegrityChecker
{
/** @var OrderProcessorInterface */
private $orderProcessors;
/** @var RouterInterface */
private $router;
/** @var OrderProcessorInterface */
private $orderProcessor;
/** @var ObjectManager */
private $manager;
public function __construct(
OrderProcessorInterface $orderProcessors,
RouterInterface $router,
OrderProcessorInterface $orderProcessor,
ObjectManager $manager
) {
$this->orderProcessors = $orderProcessors;
$this->router = $router;
$this->orderProcessor = $orderProcessor;
$this->manager = $manager;
}
@ -49,8 +51,28 @@ final class OrderTotalIntegrityChecker
Assert::isInstanceOf($order, OrderInterface::class);
$previousPromotions = new ArrayCollection($order->getPromotions()->toArray());
$oldTotal = $order->getTotal();
$this->orderProcessors->process($order);
$this->orderProcessor->process($order);
/** @var PromotionInterface $previousPromotion */
foreach ($previousPromotions as $previousPromotion) {
if (!$order->getPromotions()->contains($previousPromotion)) {
$event->stop(
'sylius.order.promotion_integrity',
ResourceControllerEvent::TYPE_ERROR,
['%promotionName%' => $previousPromotion->getName()]
);
$event->setResponse(new RedirectResponse($this->router->generate('sylius_shop_checkout_complete')));
$this->manager->persist($order);
$this->manager->flush();
return;
}
}
if ($order->getTotal() !== $oldTotal) {
$event->stop('sylius.order.total_integrity', ResourceControllerEvent::TYPE_ERROR);
@ -58,6 +80,8 @@ final class OrderTotalIntegrityChecker
$this->manager->persist($order);
$this->manager->flush();
return;
}
}
}

View file

@ -1,96 +0,0 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\ShopBundle\EventListener;
use Sylius\Bundle\ResourceBundle\Event\ResourceControllerEvent;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Order\SyliusCartEvents;
use Sylius\Component\Promotion\Action\PromotionApplicatorInterface;
use Sylius\Component\Promotion\Checker\Eligibility\PromotionEligibilityCheckerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\GenericEvent;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\RouterInterface;
use Webmozart\Assert\Assert;
final class OrderPromotionIntegrityChecker
{
/** @var PromotionEligibilityCheckerInterface */
private $promotionEligibilityChecker;
/** @var PromotionApplicatorInterface */
private $promotionApplicator;
/** @var EventDispatcherInterface */
private $eventDispatcher;
/** @var RouterInterface */
private $router;
public function __construct(
PromotionEligibilityCheckerInterface $promotionEligibilityChecker,
EventDispatcherInterface $eventDispatcher,
RouterInterface $router,
?PromotionApplicatorInterface $promotionApplicator = null
) {
if ($promotionApplicator === null) {
@trigger_error("You need to supply an promotion applicator in order to work properly. In case you don't provide it, there will be valid cases that will fail due an incorrect recalculation.", \E_USER_DEPRECATED);
}
$this->promotionEligibilityChecker = $promotionEligibilityChecker;
$this->eventDispatcher = $eventDispatcher;
$this->router = $router;
$this->promotionApplicator = $promotionApplicator;
}
public function check(ResourceControllerEvent $event): void
{
/** @var OrderInterface $order */
$order = $event->getSubject();
Assert::isInstanceOf($order, OrderInterface::class);
// we create a new promotion collection and remove them from cart
// so we can verify with original conditions (without the price being applied before check)
$promotions = $order->getPromotions()->toArray();
if ($this->promotionApplicator !== null) {
foreach ($promotions as $promotion) {
$this->promotionApplicator->revert($order, $promotion);
$order->removePromotion($promotion);
}
}
foreach ($promotions as $promotion) {
if (!$this->promotionEligibilityChecker->isEligible($order, $promotion)) {
$event->stop(
'sylius.order.promotion_integrity',
ResourceControllerEvent::TYPE_ERROR,
['%promotionName%' => $promotion->getName()]
);
$event->setResponse(new RedirectResponse($this->router->generate('sylius_shop_checkout_complete')));
$this->eventDispatcher->dispatch(SyliusCartEvents::CART_CHANGE, new GenericEvent($order));
break;
}
if ($this->promotionApplicator !== null) {
$this->promotionApplicator->apply($order, $promotion);
}
}
}
}

View file

@ -36,17 +36,9 @@
<tag name="kernel.event_listener" event="sylius.customer.post_register" method="handleUserVerification" />
</service>
<service id="sylius.listener.order_promotion_integrity_checker" class="Sylius\Bundle\ShopBundle\EventListener\OrderPromotionIntegrityChecker">
<argument type="service" id="sylius.promotion_eligibility_checker" />
<argument type="service" id="event_dispatcher" />
<service id="sylius.listener.order_integrity_checker" class="Sylius\Bundle\ShopBundle\EventListener\OrderIntegrityChecker">
<argument type="service" id="router" />
<argument type="service" id="sylius.promotion_applicator" />
<tag name="kernel.event_listener" event="sylius.order.pre_complete" method="check" />
</service>
<service id="sylius.listener.order_total_integrity_checker" class="Sylius\Bundle\ShopBundle\EventListener\OrderTotalIntegrityChecker">
<argument type="service" id="sylius.order_processing.order_processor" />
<argument type="service" id="router" />
<argument type="service" id="sylius.manager.order" />
<tag name="kernel.event_listener" event="sylius.order.pre_complete" method="check" />
</service>

View file

@ -44,8 +44,7 @@ final class SyliusShopExtensionTest extends AbstractExtensionTestCase
$this->assertContainerBuilderHasService('sylius.listener.order_customer_ip');
$this->assertContainerBuilderHasService('sylius.listener.order_complete');
$this->assertContainerBuilderHasService('sylius.listener.user_registration');
$this->assertContainerBuilderHasService('sylius.listener.order_promotion_integrity_checker');
$this->assertContainerBuilderHasService('sylius.listener.order_total_integrity_checker');
$this->assertContainerBuilderHasService('sylius.listener.order_integrity_checker');
$this->assertContainerBuilderHasService('sylius.order_locale_assigner');
}

View file

@ -0,0 +1,163 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace spec\Sylius\Bundle\ShopBundle\EventListener;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Persistence\ObjectManager;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Sylius\Bundle\ResourceBundle\Event\ResourceControllerEvent;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\PromotionInterface;
use Sylius\Component\Order\Processor\OrderProcessorInterface;
use Sylius\Component\Promotion\Action\PromotionApplicatorInterface;
use Sylius\Component\Promotion\Checker\Eligibility\PromotionEligibilityCheckerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\RouterInterface;
final class OrderIntegrityCheckerSpec extends ObjectBehavior
{
function let(
RouterInterface $router,
OrderProcessorInterface $orderProcessor,
ObjectManager $orderManager
): void {
$this->beConstructedWith($router, $orderProcessor, $orderManager);
}
function it_does_nothing_if_given_order_has_valid_promotion_applied(
OrderProcessorInterface $orderProcessor,
OrderInterface $order,
PromotionInterface $promotion,
ResourceControllerEvent $event
): void {
$event->getSubject()->willReturn($order);
$order->getPromotions()->willReturn(new ArrayCollection([$promotion->getWrappedObject()]));
$order->getTotal()->willReturn(1000);
$orderProcessor->process($order)->shouldBeCalled();
$event->stop(Argument::any())->shouldNotBeCalled();
$event->setResponse(Argument::any())->shouldNotBeCalled();
$this->check($event);
}
function it_stops_future_action_if_given_order_has_different_promotion_applied(
OrderProcessorInterface $orderProcessor,
RouterInterface $router,
OrderInterface $order,
PromotionInterface $oldPromotion,
PromotionInterface $newPromotion,
ResourceControllerEvent $event,
ObjectManager $orderManager
): void {
$event->getSubject()->willReturn($order);
$order->getPromotions()->willReturn(
new ArrayCollection([$oldPromotion->getWrappedObject()]),
new ArrayCollection([$newPromotion->getWrappedObject()])
);
$order->getTotal()->willReturn(1000);
$oldPromotion->getName()->willReturn('Christmas');
$router->generate('sylius_shop_checkout_complete')->willReturn('checkout.com');
$orderProcessor->process($order)->shouldBeCalled();
$event->stop(
'sylius.order.promotion_integrity',
ResourceControllerEvent::TYPE_ERROR,
['%promotionName%' => 'Christmas']
)->shouldBeCalled();
$event->setResponse(new RedirectResponse('checkout.com'))->shouldBeCalled();
$orderManager->persist($order)->shouldBeCalled();
$orderManager->flush()->shouldBeCalled();
$this->check($event);
}
function it_stops_future_action_if_given_order_has_different_total_value(
OrderProcessorInterface $orderProcessor,
RouterInterface $router,
OrderInterface $order,
PromotionInterface $promotion,
ResourceControllerEvent $event,
ObjectManager $orderManager
): void {
$event->getSubject()->willReturn($order);
$order->getPromotions()->willReturn(new ArrayCollection([$promotion->getWrappedObject()]));
$order->getTotal()->willReturn(1000, 1500);
$router->generate('sylius_shop_checkout_complete')->willReturn('checkout.com');
$orderProcessor->process($order)->shouldBeCalled();
$event->stop('sylius.order.total_integrity', ResourceControllerEvent::TYPE_ERROR)->shouldBeCalled();
$event->setResponse(new RedirectResponse('checkout.com'))->shouldBeCalled();
$orderManager->persist($order)->shouldBeCalled();
$orderManager->flush()->shouldBeCalled();
$this->check($event);
}
function it_stops_future_action_if_given_order_has_no_promotion_applied(
OrderProcessorInterface $orderProcessor,
RouterInterface $router,
OrderInterface $order,
PromotionInterface $promotion,
ResourceControllerEvent $event,
ObjectManager $orderManager
): void {
$event->getSubject()->willReturn($order);
$order->getPromotions()->willReturn(
new ArrayCollection([$promotion->getWrappedObject()]),
new ArrayCollection([])
);
$order->getTotal()->willReturn(1000);
$promotion->getName()->willReturn('Christmas');
$promotion->getCode()->willReturn('CHRISTMAS_PROMO_CODE');
$orderProcessor->process($order)->shouldBeCalled();
$router->generate('sylius_shop_checkout_complete')->willReturn('checkout.com');
$event->stop(
'sylius.order.promotion_integrity',
ResourceControllerEvent::TYPE_ERROR,
['%promotionName%' => 'Christmas']
)->shouldBeCalled();
$event->setResponse(new RedirectResponse('checkout.com'))->shouldBeCalled();
$orderManager->persist($order)->shouldBeCalled();
$orderManager->flush()->shouldBeCalled();
$this->check($event);
}
function it_throws_invalid_argument_exception_if_event_subject_is_not_order(ResourceControllerEvent $event): void
{
$event->getSubject()->willReturn(new \stdClass());
$this->shouldThrow(\InvalidArgumentException::class)->during('check', [$event]);
}
}

View file

@ -1,89 +0,0 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace spec\Sylius\Bundle\ShopBundle\EventListener;
use Doctrine\Common\Collections\ArrayCollection;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Sylius\Bundle\ResourceBundle\Event\ResourceControllerEvent;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\PromotionInterface;
use Sylius\Component\Promotion\Action\PromotionApplicatorInterface;
use Sylius\Component\Promotion\Checker\Eligibility\PromotionEligibilityCheckerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\RouterInterface;
final class OrderPromotionIntegrityCheckerSpec extends ObjectBehavior
{
function let(
PromotionEligibilityCheckerInterface $promotionEligibilityChecker,
EventDispatcherInterface $dispatcher,
RouterInterface $router,
PromotionApplicatorInterface $promotionApplicator
): void {
$this->beConstructedWith($promotionEligibilityChecker, $dispatcher, $router, $promotionApplicator);
}
function it_does_nothing_if_given_order_has_valid_promotion_applied(
PromotionEligibilityCheckerInterface $promotionEligibilityChecker,
EventDispatcherInterface $dispatcher,
OrderInterface $order,
PromotionInterface $promotion,
ResourceControllerEvent $event
): void {
$event->getSubject()->willReturn($order);
$order->getPromotions()->willReturn(new ArrayCollection([$promotion->getWrappedObject()]));
$order->removePromotion($promotion)->shouldBeCalled();
$promotionEligibilityChecker->isEligible($order, $promotion)->willReturn(true);
$event->stop(Argument::any())->shouldNotBeCalled();
$event->setResponse(Argument::any())->shouldNotBeCalled();
$dispatcher->dispatch(Argument::any(), Argument::any())->shouldNotBeCalled();
$this->check($event);
}
function it_stops_future_action_if_given_order_has_invalid_promotion_applied(
PromotionEligibilityCheckerInterface $promotionEligibilityChecker,
RouterInterface $router,
OrderInterface $order,
PromotionInterface $promotion,
ResourceControllerEvent $event
): void {
$router->generate('sylius_shop_checkout_complete')->willReturn('checkout.com');
$promotion->getName()->willReturn('Christmas');
$event->getSubject()->willReturn($order);
$order->getPromotions()->willReturn(new ArrayCollection([$promotion->getWrappedObject()]));
$order->removePromotion($promotion)->shouldBeCalled();
$promotionEligibilityChecker->isEligible($order, $promotion)->willReturn(false);
$event->stop(
'sylius.order.promotion_integrity',
ResourceControllerEvent::TYPE_ERROR,
['%promotionName%' => 'Christmas']
)->shouldBeCalled();
$event->setResponse(new RedirectResponse('checkout.com'))->shouldBeCalled();
$this->check($event);
}
function it_throws_invalid_argument_exception_if_event_subject_is_not_order(ResourceControllerEvent $event): void
{
$event->getSubject()->willReturn(new \stdClass());
$this->shouldThrow(\InvalidArgumentException::class)->during('check', [$event]);
}
}

View file

@ -1,78 +0,0 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace spec\Sylius\Bundle\ShopBundle\EventListener;
use Doctrine\Common\Persistence\ObjectManager;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Sylius\Bundle\ResourceBundle\Event\ResourceControllerEvent;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Order\Processor\OrderProcessorInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\RouterInterface;
final class OrderTotalIntegrityCheckerSpec extends ObjectBehavior
{
function let(OrderProcessorInterface $orderProcessor, RouterInterface $router, ObjectManager $manager): void
{
$this->beConstructedWith($orderProcessor, $router, $manager);
}
function it_does_nothing_if_prices_do_not_change(
OrderProcessorInterface $orderProcessor,
OrderInterface $order,
ResourceControllerEvent $event
): void {
$event->getSubject()->willReturn($order);
$orderProcessor->process($order)->shouldBeCalled();
$order->getTotal()->willReturn(1000);
$order->getTotal()->willReturn(1000);
$event->stop(Argument::any())->shouldNotBeCalled();
$event->setResponse(Argument::any())->shouldNotBeCalled();
$this->check($event);
}
function it_stops_process_when_it_detects_any_difference_in_order_total(
OrderProcessorInterface $orderProcessor,
RouterInterface $router,
ObjectManager $manager,
OrderInterface $order,
ResourceControllerEvent $event
): void {
$event->getSubject()->willReturn($order);
$order->getTotal()->willReturn(1000, 1500);
$router->generate('sylius_shop_checkout_complete')->willReturn('checkout-complete.com');
$orderProcessor->process($order)->shouldBeCalled();
$event->stop('sylius.order.total_integrity', ResourceControllerEvent::TYPE_ERROR)->shouldBeCalled();
$event->setResponse(new RedirectResponse('checkout-complete.com'))->shouldBeCalled();
$manager->persist($order)->shouldBeCalled();
$manager->flush()->shouldBeCalled();
$this->check($event);
}
function it_throws_invalid_argument_exception_if_subject_it_not_order(ResourceControllerEvent $event): void
{
$event->getSubject()->willReturn(new \stdClass());
$this->shouldThrow(\InvalidArgumentException::class)->during('check', [$event]);
}
}