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

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