vendor/shopware/core/Checkout/Promotion/Validator/PromotionValidator.php line 64

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