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 $payload, ConstraintViolationList $violationList, int $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 $payload, ConstraintViolationList $violationList, int $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 $data, string $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, $invalidValue, string $propertyPath, string $code, int $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. }