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

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