platform/src/Core/Checkout/Promotion/Validator/PromotionValidator.php line 59

Open in your IDE?
  1. <?php declare(strict_types=1);
  2. namespace Shopware\Core\Checkout\Promotion\Validator;
  3. use Doctrine\DBAL\Connection;
  4. use Shopware\Core\Checkout\Promotion\Aggregate\PromotionDiscount\PromotionDiscountDefinition;
  5. use Shopware\Core\Checkout\Promotion\Aggregate\PromotionDiscount\PromotionDiscountEntity;
  6. use Shopware\Core\Checkout\Promotion\PromotionDefinition;
  7. use Shopware\Core\Framework\Api\Exception\ResourceNotFoundException;
  8. use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\InsertCommand;
  9. use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\UpdateCommand;
  10. use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\WriteCommand;
  11. use Shopware\Core\Framework\DataAbstractionLayer\Write\Validation\PreWriteValidationEvent;
  12. use Shopware\Core\Framework\Validation\WriteConstraintViolationException;
  13. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  14. use Symfony\Component\Validator\ConstraintViolation;
  15. use Symfony\Component\Validator\ConstraintViolationInterface;
  16. use Symfony\Component\Validator\ConstraintViolationList;
  17. class PromotionValidator implements EventSubscriberInterface
  18. {
  19.     /**
  20.      * this is the min value for all types
  21.      * (absolute, percentage, ...)
  22.      */
  23.     private const DISCOUNT_MIN_VALUE 0.00;
  24.     /**
  25.      * this is used for the maximum allowed
  26.      * percentage discount.
  27.      */
  28.     private const DISCOUNT_PERCENTAGE_MAX_VALUE 100.0;
  29.     private Connection $connection;
  30.     private array $databasePromotions;
  31.     private array $databaseDiscounts;
  32.     public function __construct(Connection $connection)
  33.     {
  34.         $this->connection $connection;
  35.     }
  36.     public static function getSubscribedEvents(): array
  37.     {
  38.         return [
  39.             PreWriteValidationEvent::class => 'preValidate',
  40.         ];
  41.     }
  42.     /**
  43.      * This function validates our incoming delta-values for promotions
  44.      * and its aggregation. It does only check for business relevant rules and logic.
  45.      * All primitive "required" constraints are done inside the definition of the entity.
  46.      *
  47.      * @throws WriteConstraintViolationException
  48.      */
  49.     public function preValidate(PreWriteValidationEvent $event): void
  50.     {
  51.         $this->collect($event->getCommands());
  52.         $violationList = new ConstraintViolationList();
  53.         $writeCommands $event->getCommands();
  54.         foreach ($writeCommands as $index => $command) {
  55.             if (!$command instanceof InsertCommand && !$command instanceof UpdateCommand) {
  56.                 continue;
  57.             }
  58.             switch (\get_class($command->getDefinition())) {
  59.                 case PromotionDefinition::class:
  60.                     /** @var string $promotionId */
  61.                     $promotionId $command->getPrimaryKey()['id'];
  62.                     try {
  63.                         /** @var array $promotion */
  64.                         $promotion $this->getPromotionById($promotionId);
  65.                     } catch (ResourceNotFoundException $ex) {
  66.                         $promotion = [];
  67.                     }
  68.                     $this->validatePromotion(
  69.                         $promotion,
  70.                         $command->getPayload(),
  71.                         $violationList,
  72.                         $index
  73.                     );
  74.                     break;
  75.                 case PromotionDiscountDefinition::class:
  76.                     /** @var string $discountId */
  77.                     $discountId $command->getPrimaryKey()['id'];
  78.                     try {
  79.                         /** @var array $discount */
  80.                         $discount $this->getDiscountById($discountId);
  81.                     } catch (ResourceNotFoundException $ex) {
  82.                         $discount = [];
  83.                     }
  84.                     $this->validateDiscount(
  85.                         $discount,
  86.                         $command->getPayload(),
  87.                         $violationList,
  88.                         $index
  89.                     );
  90.                     break;
  91.             }
  92.         }
  93.         if ($violationList->count() > 0) {
  94.             $event->getExceptions()->add(new WriteConstraintViolationException($violationList));
  95.         }
  96.     }
  97.     /**
  98.      * This function collects all database data that might be
  99.      * required for any of the received entities and values.
  100.      *
  101.      * @throws ResourceNotFoundException
  102.      * @throws \Doctrine\DBAL\DBALException
  103.      */
  104.     private function collect(array $writeCommands): void
  105.     {
  106.         $promotionIds = [];
  107.         $discountIds = [];
  108.         /** @var WriteCommand $command */
  109.         foreach ($writeCommands as $command) {
  110.             if (!$command instanceof InsertCommand && !$command instanceof UpdateCommand) {
  111.                 continue;
  112.             }
  113.             switch (\get_class($command->getDefinition())) {
  114.                 case PromotionDefinition::class:
  115.                     $promotionIds[] = $command->getPrimaryKey()['id'];
  116.                     break;
  117.                 case PromotionDiscountDefinition::class:
  118.                     $discountIds[] = $command->getPrimaryKey()['id'];
  119.                     break;
  120.             }
  121.         }
  122.         // why do we have inline sql queries in here?
  123.         // because we want to avoid any other private functions that accidentally access
  124.         // the database. all private getters should only access the local in-memory list
  125.         // to avoid additional database queries.
  126.         $promotionQuery $this->connection->executeQuery(
  127.             'SELECT * FROM `promotion` WHERE `id` IN (:ids)',
  128.             ['ids' => $promotionIds],
  129.             ['ids' => Connection::PARAM_STR_ARRAY]
  130.         );
  131.         $this->databasePromotions $promotionQuery->fetchAll();
  132.         $discountQuery $this->connection->executeQuery(
  133.             'SELECT * FROM `promotion_discount` WHERE `id` IN (:ids)',
  134.             ['ids' => $discountIds],
  135.             ['ids' => Connection::PARAM_STR_ARRAY]
  136.         );
  137.         $this->databaseDiscounts $discountQuery->fetchAll();
  138.     }
  139.     /**
  140.      * Validates the provided Promotion data and adds
  141.      * violations to the provided list of violations, if found.
  142.      *
  143.      * @param array                   $promotion     the current promotion from the database as array type
  144.      * @param array                   $payload       the incoming delta-data
  145.      * @param ConstraintViolationList $violationList the list of violations that needs to be filled
  146.      * @param int                     $index         the index of this promotion in the command queue
  147.      *
  148.      * @throws \Exception
  149.      */
  150.     private function validatePromotion(array $promotion, array $payloadConstraintViolationList $violationListint $index): void
  151.     {
  152.         /** @var string|null $validFrom */
  153.         $validFrom $this->getValue($payload'valid_from'$promotion);
  154.         /** @var string|null $validUntil */
  155.         $validUntil $this->getValue($payload'valid_until'$promotion);
  156.         /** @var bool $useCodes */
  157.         $useCodes $this->getValue($payload'use_codes'$promotion);
  158.         /** @var bool $useCodesIndividual */
  159.         $useCodesIndividual $this->getValue($payload'use_individual_codes'$promotion);
  160.         /** @var string|null $pattern */
  161.         $pattern $this->getValue($payload'individual_code_pattern'$promotion);
  162.         /** @var string|null $promotionId */
  163.         $promotionId $this->getValue($payload'id'$promotion);
  164.         /** @var string|null $code */
  165.         $code $this->getValue($payload'code'$promotion);
  166.         if ($code === null) {
  167.             $code '';
  168.         }
  169.         if ($pattern === null) {
  170.             $pattern '';
  171.         }
  172.         $trimmedCode trim($code);
  173.         // if we have both a date from and until, make sure that
  174.         // the dateUntil is always in the future.
  175.         if ($validFrom !== null && $validUntil !== null) {
  176.             // now convert into real date times
  177.             // and start comparing them
  178.             $dateFrom = new \DateTime($validFrom);
  179.             $dateUntil = new \DateTime($validUntil);
  180.             if ($dateUntil $dateFrom) {
  181.                 $violationList->add($this->buildViolation(
  182.                     'Expiration Date of Promotion must be after Start of Promotion',
  183.                     $payload['valid_until'],
  184.                     'validUntil',
  185.                     'PROMOTION_VALID_UNTIL_VIOLATION',
  186.                     $index
  187.                 ));
  188.             }
  189.         }
  190.         // check if we use global codes
  191.         if ($useCodes && !$useCodesIndividual) {
  192.             // make sure the code is not empty
  193.             if ($trimmedCode === '') {
  194.                 $violationList->add($this->buildViolation(
  195.                     'Please provide a valid code',
  196.                     $code,
  197.                     'code',
  198.                     'PROMOTION_EMPTY_CODE_VIOLATION',
  199.                     $index
  200.                 ));
  201.             }
  202.             // if our code length is greater than the trimmed one,
  203.             // this means we have leading or trailing whitespaces
  204.             if (mb_strlen($code) > mb_strlen($trimmedCode)) {
  205.                 $violationList->add($this->buildViolation(
  206.                     'Code may not have any leading or ending whitespaces',
  207.                     $code,
  208.                     'code',
  209.                     'PROMOTION_CODE_WHITESPACE_VIOLATION',
  210.                     $index
  211.                 ));
  212.             }
  213.         }
  214.         if ($pattern !== '' && $this->isCodePatternAlreadyUsed($pattern$promotionId)) {
  215.             $violationList->add($this->buildViolation(
  216.                 'Code Pattern already exists in other promotion. Please provide a different pattern.',
  217.                 $pattern,
  218.                 'individualCodePattern',
  219.                 'PROMOTION_DUPLICATE_PATTERN_VIOLATION',
  220.                 $index
  221.             ));
  222.         }
  223.         // lookup global code if it does already exist in database
  224.         if ($trimmedCode !== '' && $this->isCodeAlreadyUsed($trimmedCode$promotionId)) {
  225.             $violationList->add($this->buildViolation(
  226.                 'Code already exists in other promotion. Please provide a different code.',
  227.                 $trimmedCode,
  228.                 'code',
  229.                 'PROMOTION_DUPLICATED_CODE_VIOLATION',
  230.                 $index
  231.             ));
  232.         }
  233.     }
  234.     /**
  235.      * Validates the provided PromotionDiscount data and adds
  236.      * violations to the provided list of violations, if found.
  237.      *
  238.      * @param array                   $discount      the discount as array from the database
  239.      * @param array                   $payload       the incoming delta-data
  240.      * @param ConstraintViolationList $violationList the list of violations that needs to be filled
  241.      */
  242.     private function validateDiscount(array $discount, array $payloadConstraintViolationList $violationListint $index): void
  243.     {
  244.         /** @var string $type */
  245.         $type $this->getValue($payload'type'$discount);
  246.         /** @var float|null $value */
  247.         $value $this->getValue($payload'value'$discount);
  248.         if ($value === null) {
  249.             return;
  250.         }
  251.         if ($value self::DISCOUNT_MIN_VALUE) {
  252.             $violationList->add($this->buildViolation(
  253.                 'Value must not be less than ' self::DISCOUNT_MIN_VALUE,
  254.                 $value,
  255.                 'value',
  256.                 'PROMOTION_DISCOUNT_MIN_VALUE_VIOLATION',
  257.                 $index
  258.             ));
  259.         }
  260.         switch ($type) {
  261.             case PromotionDiscountEntity::TYPE_PERCENTAGE:
  262.                 if ($value self::DISCOUNT_PERCENTAGE_MAX_VALUE) {
  263.                     $violationList->add($this->buildViolation(
  264.                         'Absolute value must not greater than ' self::DISCOUNT_PERCENTAGE_MAX_VALUE,
  265.                         $value,
  266.                         'value',
  267.                         'PROMOTION_DISCOUNT_MAX_VALUE_VIOLATION',
  268.                         $index
  269.                     ));
  270.                 }
  271.                 break;
  272.         }
  273.     }
  274.     /**
  275.      * Gets a value from an array. It also does clean checks if
  276.      * the key is set, and also provides the option for default values.
  277.      *
  278.      * @param array  $data  the data array
  279.      * @param string $key   the requested key in the array
  280.      * @param array  $dbRow the db row of from the database
  281.      *
  282.      * @return mixed the object found in the key, or the default value
  283.      */
  284.     private function getValue(array $datastring $key, array $dbRow)
  285.     {
  286.         // try in our actual data set
  287.         if (isset($data[$key])) {
  288.             return $data[$key];
  289.         }
  290.         // try in our db row fallback
  291.         if (isset($dbRow[$key])) {
  292.             return $dbRow[$key];
  293.         }
  294.         // use default
  295.         return null;
  296.     }
  297.     /**
  298.      * @throws ResourceNotFoundException
  299.      *
  300.      * @return array|mixed
  301.      */
  302.     private function getPromotionById(string $id)
  303.     {
  304.         /** @var array $promotion */
  305.         foreach ($this->databasePromotions as $promotion) {
  306.             if ($promotion['id'] === $id) {
  307.                 return $promotion;
  308.             }
  309.         }
  310.         throw new ResourceNotFoundException('promotion', [$id]);
  311.     }
  312.     /**
  313.      * @throws ResourceNotFoundException
  314.      *
  315.      * @return array|mixed
  316.      */
  317.     private function getDiscountById(string $id)
  318.     {
  319.         /** @var array $discount */
  320.         foreach ($this->databaseDiscounts as $discount) {
  321.             if ($discount['id'] === $id) {
  322.                 return $discount;
  323.             }
  324.         }
  325.         throw new ResourceNotFoundException('promotion_discount', [$id]);
  326.     }
  327.     /**
  328.      * This helper function builds an easy violation
  329.      * object for our validator.
  330.      *
  331.      * @param string $message      the error message
  332.      * @param mixed  $invalidValue the actual invalid value
  333.      * @param string $propertyPath the property path from the root value to the invalid value without initial slash
  334.      * @param string $code         the error code of the violation
  335.      * @param int    $index        the position of this entity in the command queue
  336.      *
  337.      * @return ConstraintViolationInterface the built constraint violation
  338.      */
  339.     private function buildViolation(string $message$invalidValuestring $propertyPathstring $codeint $index): ConstraintViolationInterface
  340.     {
  341.         $formattedPath "/{$index}/{$propertyPath}";
  342.         return new ConstraintViolation(
  343.             $message,
  344.             '',
  345.             [
  346.                 'value' => $invalidValue,
  347.             ],
  348.             $invalidValue,
  349.             $formattedPath,
  350.             $invalidValue,
  351.             null,
  352.             $code
  353.         );
  354.     }
  355.     /**
  356.      * True, if the provided pattern is already used in another promotion.
  357.      */
  358.     private function isCodePatternAlreadyUsed(string $pattern, ?string $promotionId): bool
  359.     {
  360.         $qb $this->connection->createQueryBuilder();
  361.         $query $qb
  362.             ->select('id')
  363.             ->from('promotion')
  364.             ->where($qb->expr()->eq('individual_code_pattern'':pattern'))
  365.             ->setParameter(':pattern'$pattern);
  366.         $promotions $query->execute()->fetchAll();
  367.         /** @var array $p */
  368.         foreach ($promotions as $p) {
  369.             // if we have a promotion id to verify
  370.             // and a promotion with another id exists, then return that is used
  371.             if ($promotionId !== null && $p['id'] !== $promotionId) {
  372.                 return true;
  373.             }
  374.         }
  375.         return false;
  376.     }
  377.     /**
  378.      * True, if the provided code is already used as global
  379.      * or individual code in another promotion.
  380.      */
  381.     private function isCodeAlreadyUsed(string $code, ?string $promotionId): bool
  382.     {
  383.         $qb $this->connection->createQueryBuilder();
  384.         // check if individual code.
  385.         // if we dont have a promotion Id only
  386.         // check if its existing somewhere,
  387.         // if we have an Id, verify if it's existing in another promotion
  388.         $query $qb
  389.             ->select('id')
  390.             ->from('promotion_individual_code')
  391.             ->where($qb->expr()->eq('code'':code'))
  392.             ->setParameter(':code'$code);
  393.         if ($promotionId !== null) {
  394.             $query->andWhere($qb->expr()->neq('promotion_id'':promotion_id'))
  395.                 ->setParameter(':promotion_id'$promotionId);
  396.         }
  397.         $existingIndividual \count($query->execute()->fetchAll()) > 0;
  398.         if ($existingIndividual) {
  399.             return true;
  400.         }
  401.         $qb $this->connection->createQueryBuilder();
  402.         // check if it is a global promotion code.
  403.         // again with either an existing promotion Id
  404.         // or without one.
  405.         $query
  406.             $qb->select('id')
  407.             ->from('promotion')
  408.             ->where($qb->expr()->eq('code'':code'))
  409.             ->setParameter(':code'$code);
  410.         if ($promotionId !== null) {
  411.             $query->andWhere($qb->expr()->neq('id'':id'))
  412.                 ->setParameter(':id'$promotionId);
  413.         }
  414.         return \count($query->execute()->fetchAll()) > 0;
  415.     }
  416. }