diff --git a/dist/.gitkeep b/dist/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/dist/config/packages/monsieurbiz_sylius_advanced_promotion_plugin.yaml b/dist/config/packages/monsieurbiz_sylius_advanced_promotion_plugin.yaml new file mode 100644 index 0000000..9dccaad --- /dev/null +++ b/dist/config/packages/monsieurbiz_sylius_advanced_promotion_plugin.yaml @@ -0,0 +1,2 @@ +imports: + resource: '@MonsieurBizSyliusAdvancedPromotionPlugin/Resources/config/config.yaml' diff --git a/dist/config/routes/monsieurbiz_sylius_advanced_promotion_plugin.yaml b/dist/config/routes/monsieurbiz_sylius_advanced_promotion_plugin.yaml new file mode 100644 index 0000000..1d0596f --- /dev/null +++ b/dist/config/routes/monsieurbiz_sylius_advanced_promotion_plugin.yaml @@ -0,0 +1,2 @@ +imports: + resource: '@MonsieurBizSyliusAdvancedPromotionPlugin/Resources/config/routes.yaml' diff --git a/dist/src/Entity/Promotion/Promotion.php b/dist/src/Entity/Promotion/Promotion.php new file mode 100644 index 0000000..5406b96 --- /dev/null +++ b/dist/src/Entity/Promotion/Promotion.php @@ -0,0 +1,27 @@ + + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace App\Entity\Promotion; + +use Doctrine\ORM\Mapping as ORM; +use MonsieurBiz\SyliusAdvancedPromotionPlugin\Entity\AfterTaxAwareTrait; +use Sylius\Component\Core\Model\Promotion as BasePromotion; + +/** + * @ORM\Entity + * @ORM\Table(name="sylius_promotion") + */ +#[ORM\Entity] +#[ORM\Table(name: 'sylius_promotion')] +class Promotion extends BasePromotion implements PromotionInterface +{ + use AfterTaxAwareTrait; +} diff --git a/dist/src/Entity/Promotion/PromotionInterface.php b/dist/src/Entity/Promotion/PromotionInterface.php new file mode 100644 index 0000000..90db7e8 --- /dev/null +++ b/dist/src/Entity/Promotion/PromotionInterface.php @@ -0,0 +1,19 @@ + + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace App\Entity\Promotion; + +use MonsieurBiz\SyliusAdvancedPromotionPlugin\Entity\AfterTaxAwareInterface; +use Sylius\Component\Core\Model\PromotionInterface as BasePromotionInterface; + +interface PromotionInterface extends BasePromotionInterface, AfterTaxAwareInterface +{ +} diff --git a/dist/src/Migrations/Version20231211134913.php b/dist/src/Migrations/Version20231211134913.php new file mode 100644 index 0000000..e033444 --- /dev/null +++ b/dist/src/Migrations/Version20231211134913.php @@ -0,0 +1,38 @@ + + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace App\Migrations; + +use Doctrine\DBAL\Schema\Schema; +use Doctrine\Migrations\AbstractMigration; + +/** + * Auto-generated Migration: Please modify to your needs! + */ +final class Version20231211134913 extends AbstractMigration +{ + public function getDescription(): string + { + return ''; + } + + public function up(Schema $schema): void + { + // this up() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE sylius_promotion ADD after_tax TINYINT(1) DEFAULT 0 NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE sylius_promotion DROP after_tax'); + } +} diff --git a/src/Context/PromotionContext.php b/src/Context/PromotionContext.php new file mode 100644 index 0000000..49ba032 --- /dev/null +++ b/src/Context/PromotionContext.php @@ -0,0 +1,27 @@ + + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace MonsieurBiz\SyliusAdvancedPromotionPlugin\Context; + +class PromotionContext implements PromotionContextInterface +{ + protected bool $afterTax = false; + + public function isAfterTax(): bool + { + return $this->afterTax ?? false; + } + + public function setAfterTax(bool $afterTax): void + { + $this->afterTax = $afterTax; + } +} diff --git a/src/Context/PromotionContextInterface.php b/src/Context/PromotionContextInterface.php new file mode 100644 index 0000000..de7e633 --- /dev/null +++ b/src/Context/PromotionContextInterface.php @@ -0,0 +1,19 @@ + + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace MonsieurBiz\SyliusAdvancedPromotionPlugin\Context; + +interface PromotionContextInterface +{ + public function isAfterTax(): bool; + + public function setAfterTax(bool $afterTax): void; +} diff --git a/src/Entity/AfterTaxAwareInterface.php b/src/Entity/AfterTaxAwareInterface.php new file mode 100644 index 0000000..40f307d --- /dev/null +++ b/src/Entity/AfterTaxAwareInterface.php @@ -0,0 +1,19 @@ + + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace MonsieurBiz\SyliusAdvancedPromotionPlugin\Entity; + +interface AfterTaxAwareInterface +{ + public function isAfterTax(): bool; + + public function setAfterTax(?bool $afterTax): void; +} diff --git a/src/Entity/AfterTaxAwareTrait.php b/src/Entity/AfterTaxAwareTrait.php new file mode 100644 index 0000000..2a2851a --- /dev/null +++ b/src/Entity/AfterTaxAwareTrait.php @@ -0,0 +1,33 @@ + + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace MonsieurBiz\SyliusAdvancedPromotionPlugin\Entity; + +use Doctrine\ORM\Mapping as ORM; + +trait AfterTaxAwareTrait +{ + /** + * @ORM\Column(name="after_tax", type="boolean", nullable=false, options={"default": false}) + */ + #[ORM\Column(name: 'after_tax', type: 'boolean', nullable: false, options: ['default' => false])] + protected bool $afterTax = false; + + public function isAfterTax(): bool + { + return $this->afterTax; + } + + public function setAfterTax(?bool $afterTax): void + { + $this->afterTax = (bool) $afterTax; + } +} diff --git a/src/Fixture/AdvancedPromotionFixture.php b/src/Fixture/AdvancedPromotionFixture.php new file mode 100644 index 0000000..82bc044 --- /dev/null +++ b/src/Fixture/AdvancedPromotionFixture.php @@ -0,0 +1,63 @@ + + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace MonsieurBiz\SyliusAdvancedPromotionPlugin\Fixture; + +use Doctrine\ORM\EntityManagerInterface; +use MonsieurBiz\SyliusAdvancedPromotionPlugin\Entity\AfterTaxAwareInterface; +use Sylius\Bundle\FixturesBundle\Fixture\AbstractFixture; +use Sylius\Bundle\FixturesBundle\Fixture\FixtureInterface; +use Sylius\Component\Core\Repository\PromotionRepositoryInterface; +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; + +final class AdvancedPromotionFixture extends AbstractFixture implements FixtureInterface +{ + public function __construct( + private PromotionRepositoryInterface $promotionRepository, + private EntityManagerInterface $entityManager, + ) { + } + + public function getName(): string + { + return 'monsieurbiz_advanced_promotion'; + } + + public function load(array $options): void + { + foreach ($options['promotion_advanced_configuration'] as $data) { + $promotion = $this->promotionRepository->findOneBy(['code' => $data['code']]); + if (null === $promotion || !$promotion instanceof AfterTaxAwareInterface) { + continue; + } + $promotion->setAfterTax($data['after_tax']); + $this->entityManager->persist($promotion); + } + + $this->entityManager->flush(); + } + + protected function configureOptionsNode(ArrayNodeDefinition $optionsNode): void + { + /** @phpstan-ignore-next-line */ + $optionsNode + ->children() + ->arrayNode('promotion_advanced_configuration') + ->arrayPrototype() + ->children() + ->scalarNode('code')->cannotBeEmpty()->end() + ->booleanNode('after_tax')->defaultFalse()->end() + ->end() + ->end() + ->end() + ; + } +} diff --git a/src/Form/Extension/PromotionTypeExtension.php b/src/Form/Extension/PromotionTypeExtension.php new file mode 100644 index 0000000..c0e954a --- /dev/null +++ b/src/Form/Extension/PromotionTypeExtension.php @@ -0,0 +1,40 @@ + + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace MonsieurBiz\SyliusAdvancedPromotionPlugin\Form\Extension; + +use Sylius\Bundle\PromotionBundle\Form\Type\PromotionType; +use Symfony\Component\Form\AbstractTypeExtension; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\FormBuilderInterface; + +final class PromotionTypeExtension extends AbstractTypeExtension +{ + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('afterTax', CheckboxType::class, [ + 'required' => false, + 'label' => 'monsieurbiz_sylius_advanced_promotion.promotion.applied_after_tax', + ]) + ; + } + + public static function getExtendedTypes(): iterable + { + return [ + PromotionType::class, + ]; + } +} diff --git a/src/Promotion/Checker/Eligibility/PromotionTaxContextEligibilityChecker.php b/src/Promotion/Checker/Eligibility/PromotionTaxContextEligibilityChecker.php new file mode 100644 index 0000000..56f476b --- /dev/null +++ b/src/Promotion/Checker/Eligibility/PromotionTaxContextEligibilityChecker.php @@ -0,0 +1,40 @@ + + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace MonsieurBiz\SyliusAdvancedPromotionPlugin\Promotion\Checker\Eligibility; + +use MonsieurBiz\SyliusAdvancedPromotionPlugin\Context\PromotionContextInterface; +use MonsieurBiz\SyliusAdvancedPromotionPlugin\Entity\AfterTaxAwareInterface; +use Sylius\Component\Promotion\Checker\Eligibility\PromotionEligibilityCheckerInterface; +use Sylius\Component\Promotion\Model\PromotionInterface; +use Sylius\Component\Promotion\Model\PromotionSubjectInterface; +use Webmozart\Assert\Assert; + +final class PromotionTaxContextEligibilityChecker implements PromotionEligibilityCheckerInterface +{ + public function __construct( + private PromotionContextInterface $promotionContext + ) { + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function isEligible( + PromotionSubjectInterface $promotionSubject, + PromotionInterface $promotion + ): bool { + /** @var AfterTaxAwareInterface $promotion */ + Assert::isInstanceOf($promotion, AfterTaxAwareInterface::class); + + return $this->promotionContext->isAfterTax() === $promotion->isAfterTax(); + } +} diff --git a/src/Promotion/Processor/AfterTaxPromotionProcessor.php b/src/Promotion/Processor/AfterTaxPromotionProcessor.php new file mode 100644 index 0000000..071ca92 --- /dev/null +++ b/src/Promotion/Processor/AfterTaxPromotionProcessor.php @@ -0,0 +1,75 @@ + + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace MonsieurBiz\SyliusAdvancedPromotionPlugin\Promotion\Processor; + +use MonsieurBiz\SyliusAdvancedPromotionPlugin\Context\PromotionContextInterface; +use MonsieurBiz\SyliusAdvancedPromotionPlugin\Entity\AfterTaxAwareInterface; +use Sylius\Component\Promotion\Action\PromotionApplicatorInterface; +use Sylius\Component\Promotion\Checker\Eligibility\PromotionEligibilityCheckerInterface; +use Sylius\Component\Promotion\Model\PromotionSubjectInterface; +use Sylius\Component\Promotion\Processor\PromotionProcessorInterface; +use Sylius\Component\Promotion\Provider\PreQualifiedPromotionsProviderInterface; +use Webmozart\Assert\Assert; + +final class AfterTaxPromotionProcessor implements PromotionProcessorInterface +{ + public function __construct( + private PreQualifiedPromotionsProviderInterface $preQualifiedPromotionsProvider, + private PromotionEligibilityCheckerInterface $promotionEligibilityChecker, + private PromotionApplicatorInterface $promotionApplicator, + private PromotionContextInterface $promotionContext + ) { + } + + /** + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ + public function process(PromotionSubjectInterface $subject): void + { + // Set the promotion context to after cart + $this->promotionContext->setAfterTax(true); + + /** + * We do not revert applied promotions because we want to keep the rules applies before tax. + * + * @see \Sylius\Component\Promotion\Processor\PromotionProcessor::process() + */ + + // Retrieve promotion after tax, it's a double security because the PromotionTaxContextEligibilityChecker is also called + $preQualifiedPromotions = $this->getPromotionsAfterTax($subject); + + foreach ($preQualifiedPromotions as $promotion) { + if ($promotion->isExclusive() && $this->promotionEligibilityChecker->isEligible($subject, $promotion)) { + $this->promotionApplicator->apply($subject, $promotion); + + return; + } + } + + foreach ($preQualifiedPromotions as $promotion) { + if (!$promotion->isExclusive() && $this->promotionEligibilityChecker->isEligible($subject, $promotion)) { + $this->promotionApplicator->apply($subject, $promotion); + } + } + } + + private function getPromotionsAfterTax(PromotionSubjectInterface $subject): array + { + $preQualifiedPromotions = $this->preQualifiedPromotionsProvider->getPromotions($subject); + + return array_filter($preQualifiedPromotions, function ($promotion) { + Assert::isInstanceOf($promotion, AfterTaxAwareInterface::class); + + return $promotion->isAfterTax(); + }); + } +} diff --git a/src/Resources/config/config.yaml b/src/Resources/config/config.yaml index 52f86f2..4b43a08 100644 --- a/src/Resources/config/config.yaml +++ b/src/Resources/config/config.yaml @@ -1 +1,4 @@ -# imports: +imports: + - { resource: 'fixtures/*.yaml' } + - { resource: 'grids/*.yaml' } + - { resource: 'ui/*.yaml' } diff --git a/src/Resources/config/fixtures/advanced_promotion.yaml b/src/Resources/config/fixtures/advanced_promotion.yaml new file mode 100644 index 0000000..949e083 --- /dev/null +++ b/src/Resources/config/fixtures/advanced_promotion.yaml @@ -0,0 +1,9 @@ +sylius_fixtures: + suites: + default: + fixtures: + monsieurbiz_advanced_promotion: + options: + promotion_advanced_configuration: + - code: 'new_year' + after_tax: true diff --git a/src/Resources/config/grids/promotion.yaml b/src/Resources/config/grids/promotion.yaml new file mode 100644 index 0000000..230c215 --- /dev/null +++ b/src/Resources/config/grids/promotion.yaml @@ -0,0 +1,13 @@ +sylius_grid: + grids: + sylius_admin_promotion: + fields: + afterTax: + type: twig + label: monsieurbiz_sylius_advanced_promotion.promotion.applied_after_tax + options: + template: "@SyliusUi/Grid/Field/yesNo.html.twig" + filters: + afterTax: + type: boolean + label: monsieurbiz_sylius_advanced_promotion.promotion.applied_after_tax diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml index 46fb0cd..743d965 100644 --- a/src/Resources/config/services.yaml +++ b/src/Resources/config/services.yaml @@ -11,3 +11,22 @@ services: MonsieurBiz\SyliusAdvancedPromotionPlugin\Controller\: resource: '../../Controller' tags: [ 'controller.service_arguments' ] + + MonsieurBiz\SyliusAdvancedPromotionPlugin\Promotion\Checker\Eligibility\: + resource: '../../Promotion/Checker/Eligibility' + tags: [ 'sylius.promotion_eligibility_checker' ] + + # Custom processor to apply promotions after tax + MonsieurBiz\SyliusAdvancedPromotionPlugin\Promotion\Processor\AfterTaxPromotionProcessor: + arguments: + $preQualifiedPromotionsProvider: '@sylius.active_promotions_provider' + $promotionEligibilityChecker: '@sylius.promotion_eligibility_checker' + + # Add order processor to manage promotion after tax + monsieurbiz_advanced_promotion.order_processing.order_promotion_processor: + autoconfigure: false + class: Sylius\Component\Core\OrderProcessing\OrderPromotionProcessor + arguments: + $promotionProcessor: '@MonsieurBiz\SyliusAdvancedPromotionPlugin\Promotion\Processor\AfterTaxPromotionProcessor' + tags: + - { name: 'sylius.order_processor', priority: 5 } # Tax processor is 10 diff --git a/src/Resources/config/ui/promotion_form.yaml b/src/Resources/config/ui/promotion_form.yaml new file mode 100644 index 0000000..26aa1be --- /dev/null +++ b/src/Resources/config/ui/promotion_form.yaml @@ -0,0 +1,14 @@ +sylius_ui: + events: + sylius.admin.promotion.update.form: + blocks: + monsieurbiz_advanced_promotion_form: + template: '@MonsieurBizSyliusAdvancedPromotionPlugin/Admin/Promotion/_advancedPromotionForm.html.twig' + sylius.admin.promotion.create.form: + blocks: + monsieurbiz_advanced_promotion_form: + template: '@MonsieurBizSyliusAdvancedPromotionPlugin/Admin/Promotion/_advancedPromotionForm.html.twig' + monsieurbiz.advanced_promotion.admin.promotion.form: + blocks: + monsieurbiz_advanced_promotion_promotion_form: + template: '@MonsieurBizSyliusAdvancedPromotionPlugin/Admin/Promotion/_form.html.twig' diff --git a/src/Resources/translations/messages.en.yml b/src/Resources/translations/messages.en.yml new file mode 100644 index 0000000..f0cb878 --- /dev/null +++ b/src/Resources/translations/messages.en.yml @@ -0,0 +1,4 @@ +monsieurbiz_sylius_advanced_promotion: + promotion: + applied_after_tax: Applied after tax + advanced_promotion_configuration: Advanced promotion configuration diff --git a/src/Resources/translations/messages.fr.yml b/src/Resources/translations/messages.fr.yml new file mode 100644 index 0000000..d5722a7 --- /dev/null +++ b/src/Resources/translations/messages.fr.yml @@ -0,0 +1,4 @@ +monsieurbiz_sylius_advanced_promotion: + promotion: + applied_after_tax: Appliqué après la taxe + advanced_promotion_configuration: Configuration de la promotion avancée diff --git a/src/Resources/views/.gitkeep b/src/Resources/views/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/Resources/views/Admin/Promotion/_advancedPromotionForm.html.twig b/src/Resources/views/Admin/Promotion/_advancedPromotionForm.html.twig new file mode 100644 index 0000000..2c934c7 --- /dev/null +++ b/src/Resources/views/Admin/Promotion/_advancedPromotionForm.html.twig @@ -0,0 +1,6 @@ +
+ +
+

{{ 'monsieurbiz_sylius_advanced_promotion.promotion.advanced_promotion_configuration'|trans }}

+ {{ sylius_template_event('monsieurbiz.advanced_promotion.admin.promotion.form', _context) }} +
diff --git a/src/Resources/views/Admin/Promotion/_form.html.twig b/src/Resources/views/Admin/Promotion/_form.html.twig new file mode 100644 index 0000000..2e47331 --- /dev/null +++ b/src/Resources/views/Admin/Promotion/_form.html.twig @@ -0,0 +1 @@ +{{ form_row(form.afterTax) }}