From 3b566b312ef38390bb50585603beeb1ffa20eeef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Thu, 16 Oct 2025 01:20:38 +0200 Subject: [PATCH 01/16] [WIP] Current status --- code_samples/discounts/config/services.yaml | 29 ++++ .../PurchaseParityValueFormatter.php | 17 ++ docs/discounts/discounts.md | 3 +- docs/discounts/extend_discounts.md | 152 ++++++++++++++++++ mkdocs.yml | 1 + 5 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 code_samples/discounts/config/services.yaml create mode 100644 code_samples/discounts/src/Discounts/PurchaseParityValueFormatter.php create mode 100644 docs/discounts/extend_discounts.md diff --git a/code_samples/discounts/config/services.yaml b/code_samples/discounts/config/services.yaml new file mode 100644 index 0000000000..0706a557c9 --- /dev/null +++ b/code_samples/discounts/config/services.yaml @@ -0,0 +1,29 @@ +# This file is the entry point to configure your own services. +# Files in the packages/ subdirectory configure your dependencies. + +# Put parameters here that don't need to change on each machine where the app is deployed +# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration +parameters: + +services: + # default configuration for services in *this* file + _defaults: + autowire: true # Automatically injects dependencies in your services. + autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. + + # makes classes in src/ available to be used as services + # this creates a service per class whose id is the fully-qualified class name + App\: + resource: '../src/' + exclude: + - '../src/DependencyInjection/' + - '../src/Entity/' + - '../src/Kernel.php' + + # add more service definitions when explicit configuration is needed + # please note that last definitions always *replace* previous ones + + App\Discounts\CustomDiscountValueFormatter: + tags: + - name: ibexa.discounts.value.formatter + rule_type: 'referral_rule' diff --git a/code_samples/discounts/src/Discounts/PurchaseParityValueFormatter.php b/code_samples/discounts/src/Discounts/PurchaseParityValueFormatter.php new file mode 100644 index 0000000000..79049bffb5 --- /dev/null +++ b/code_samples/discounts/src/Discounts/PurchaseParityValueFormatter.php @@ -0,0 +1,17 @@ +`is_product_in_product_codes()` | `true/false`, depending if the product is part of the given list.| Conditions, rules | +| Variable | `cart` | [Cart object](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Cart-Value-CartInterface.html) associated with current context.| Conditions, rules | +| Variable | `currency` | [Currency object](/api/php_api/php_api_reference/classes/Ibexa-Contracts-ProductCatalog-Values-CurrencyInterface.html) of the current siteaccess. | Conditions, rules | +| Variable | `customer_group` | [Customer group object](/api/php_api/php_api_reference/classes/Ibexa-Contracts-ProductCatalog-Values-CustomerGroupInterface.html) associated with given price context or the current user.| Conditions, rules | +| Variable | `amount` | Original price of the product | Rules | +| Variable | `product` | [Product object](/api/php_api/php_api_reference/classes/Ibexa-Contracts-ProductCatalog-Values-ProductInterface.html)| Rules | + +### Custom expressions + +You can create your own variables and functions to make creating the conditions easier. +To create the condition checking the registration date, the following example will use an additinal variable and a function: + +- `current_user`, a variable with the current [User object](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Core-Repository-Values-User-User.html) + +To add it, create a class implementing the [`DiscountVariablesResolverInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountVariablesResolverInterface.html): + +``` php +todo: verify +``` + +And mark it as a service using the `ibexa.discounts.expression_language.variable_resolver` service tag: + +``` yaml +todo: verify +``` + +- `is_anniversary()`, a function returning a boolean value indicating if the two dates passed as arguments fall on the same day. + +``` php +todo: verify +``` + +Mark it as a service using the `ibexa.discounts.expression_language.function` service tag and specify the function name in the service definition. + +``` yaml +todo: verify +``` + +Two new expressions are now available for use in custom conditions and rules. + +### Implement custom condition + +Now, create the condition by creating a class implementing the [`DiscountConditionInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-Value-DiscountConditionInterface.html). + +``` php +todo: verify +``` + +The expression can evaluate to `true` or `false` depending on the custom expressions values. +An additional variable, `today`, is defined to store the current date for comparison. + +For each condition class you must create a dedicated condition factory, a class implementing the `\Ibexa\Discounts\Repository\DiscountCondition\DiscountConditionFactoryInterface` inteface. + +This allows you to create conditions when working in the context of the Symfony service container. + +``` php +todo +``` + +Mark it as a service using the `ibexa.discounts.condition.factory` service tag and specify the condition's identifier. + +``` yaml +todo +``` + +## Create custom rules + +To implement a custom rule, create a class implementing the [`DiscountRuleInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-Value-DiscountRuleInterface.html). + +The following example implements a [purchasing power parity discount](https://en.wikipedia.org/wiki/Purchasing_power_parity), adjusting product's price in the cart based on buyer's region. + +``` php +todo +``` + +As with conditions, create a dedicated rule factory. + +``` php +todo +``` + +Mark it as a service using the `ibexa.discounts.condition.factory` service tag and specify the rule's type. + +``` yaml +todo +``` + +### Custom discount formatting + +You can adjust how each discount type is displayed when using the [`ibexa_discounts_render_discount_badge` Twig function](discounts_twig_functions.md#ibexa_discounts_render_discount_badge) by implementing a custom formatter. + +To do it, create a class implementing the [`DiscountValueFormatterInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountValueFormatterInterface.html) and use the `ibexa.discounts.value.formatter` service tag: + +``` php +todo +``` + +``` yaml +todo +``` + +## Change discount priority + +You can change the [the defualt discount priority](discounts_guide.md#discounts-priority) by creating a class implementing the [`DiscountPrioritizationStrategyInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountPrioritizationStrategyInterface.html) and aliasing to it the default implementation. + +The example below decorates the default implementation to prioritize recently created discounts above all the others. + +``` php +todo +``` + +``` yaml +todo +``` + +## Form integration + +### Condition + +### Rules diff --git a/mkdocs.yml b/mkdocs.yml index 7cb7311839..9e70df6fb9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -416,6 +416,7 @@ nav: - Install Discounts: discounts/install_discounts.md - Customize Discounts: discounts/configure_discounts.md - Discounts API: discounts/discounts_api.md + - Extend Discounts: discounts/extend_discounts.md - Customer management: - Customer Portal: customer_management/customer_portal.md - Customer Portal guide: customer_management/customer_portal_guide.md From 740ad1e2bb3ae559278acfc6af94c685c959e30d Mon Sep 17 00:00:00 2001 From: mnocon Date: Wed, 15 Oct 2025 23:35:55 +0000 Subject: [PATCH 02/16] PHP & JS CS Fixes --- .../discounts/src/Discounts/PurchaseParityValueFormatter.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/code_samples/discounts/src/Discounts/PurchaseParityValueFormatter.php b/code_samples/discounts/src/Discounts/PurchaseParityValueFormatter.php index 79049bffb5..a88ffceac5 100644 --- a/code_samples/discounts/src/Discounts/PurchaseParityValueFormatter.php +++ b/code_samples/discounts/src/Discounts/PurchaseParityValueFormatter.php @@ -4,14 +4,13 @@ namespace App\Discounts; -use Ibexa\Contracts\Discounts\Value\DiscountInterface; use Ibexa\Contracts\Discounts\DiscountValueFormatterInterface; +use Ibexa\Contracts\Discounts\Value\DiscountInterface; use Money\Money; final class PurchaseParityValueFormatter implements DiscountValueFormatterInterface { public function format(DiscountInterface $discount, ?Money $money = null): string { - } } From 752ef128fa0757acfe88f1bf32a94d7ce93d5d8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Fri, 31 Oct 2025 10:11:20 +0100 Subject: [PATCH 03/16] Started working on Forms --- docs/discounts/extend_discounts.md | 24 ++++++++++++++--------- docs/discounts/extend_discounts_wizard.md | 19 ++++++++++++++++++ 2 files changed, 34 insertions(+), 9 deletions(-) create mode 100644 docs/discounts/extend_discounts_wizard.md diff --git a/docs/discounts/extend_discounts.md b/docs/discounts/extend_discounts.md index 63de341b44..cf99cc9c41 100644 --- a/docs/discounts/extend_discounts.md +++ b/docs/discounts/extend_discounts.md @@ -11,6 +11,13 @@ month_change: true By extending [Discounts](discounts_guide.md), you can increase flexibility and control over how promotions are applied to suit your unique business rules. Together with the existing [events](event_reference.md) and the [Discounts PHP API](discounts_api.md), extending discounts gives you the ability to cover additional use cases related to selling products. +!!! tip + + If you prefer learning from videos, two presentations from Ibexa Summit 2025 cover the Discounts feature: + + - Konrad Oboza: [Introduction to the Discounts system in Ibexa DXP](https://www.youtube.com/watch?v=kTgtxY38srw) + - Paweł Niedzielski: [Extending new Discounts to suit your needs](https://www.youtube.com/watch?v=pDJxEKJLwPs) + ## Create custom conditions With custom [conditions](discounts_api.md#conditions) you can create more advanced discounts that apply only in specific scenarios. @@ -28,7 +35,7 @@ The following expressions are available for conditions and rules: | --- | --- | --- | --- | | Function | `get_current_region()` | [Region object](/api/php_api/php_api_reference/classes/Ibexa-Contracts-ProductCatalog-Values-RegionInterface.html) of the current siteaccess.| Conditions, rules | | Function | `is_in_category()` | `true/false`, depending if a product belongs to given [product categories](pim_guide.md#product-categories).| Conditions, rules | -| Function | `is_user_in_customer_group()` | `true/false`, depending if a user belongs to given [customer groups](customer_groups.md). | Conditions, rules | +| Function | `is_user_in_customer_group()` | `true/false`, depending if an user belongs to given [customer groups](customer_groups.md). | Conditions, rules | | Function | `calculate_purchase_amount()` | Purchase amount, calculated for all products in the cart before the discounts are applied.| Conditions, rules | | Function | `is_product_in_product_codes()` | `true/false`, depending if the product is part of the given list.| Conditions, rules | | Variable | `cart` | [Cart object](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Cart-Value-CartInterface.html) associated with current context.| Conditions, rules | @@ -40,7 +47,7 @@ The following expressions are available for conditions and rules: ### Custom expressions You can create your own variables and functions to make creating the conditions easier. -To create the condition checking the registration date, the following example will use an additinal variable and a function: +To create the condition checking the registration date, the following example uses an additional variable and a function: - `current_user`, a variable with the current [User object](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Core-Repository-Values-User-User.html) @@ -79,7 +86,7 @@ todo: verify ``` The expression can evaluate to `true` or `false` depending on the custom expressions values. -An additional variable, `today`, is defined to store the current date for comparison. +An additional variable, `date`, is defined to store the current date for comparison. For each condition class you must create a dedicated condition factory, a class implementing the `\Ibexa\Discounts\Repository\DiscountCondition\DiscountConditionFactoryInterface` inteface. @@ -95,11 +102,13 @@ Mark it as a service using the `ibexa.discounts.condition.factory` service tag a todo ``` +To learn how to integrate the custom conditions into the back office, see [Extend Discounts wizard](extend_discounts_wizard.md). + ## Create custom rules To implement a custom rule, create a class implementing the [`DiscountRuleInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-Value-DiscountRuleInterface.html). -The following example implements a [purchasing power parity discount](https://en.wikipedia.org/wiki/Purchasing_power_parity), adjusting product's price in the cart based on buyer's region. +The following example implements a [purchasing power parity](https://en.wikipedia.org/wiki/Purchasing_power_parity) discount, adjusting product's price in the cart based on buyer's region. ``` php todo @@ -117,6 +126,8 @@ Mark it as a service using the `ibexa.discounts.condition.factory` service tag a todo ``` +To learn how to integrate the custom rules into the back office, see [Extend Discounts wizard](extend_discounts_wizard.md). + ### Custom discount formatting You can adjust how each discount type is displayed when using the [`ibexa_discounts_render_discount_badge` Twig function](discounts_twig_functions.md#ibexa_discounts_render_discount_badge) by implementing a custom formatter. @@ -145,8 +156,3 @@ todo todo ``` -## Form integration - -### Condition - -### Rules diff --git a/docs/discounts/extend_discounts_wizard.md b/docs/discounts/extend_discounts_wizard.md new file mode 100644 index 0000000000..0ec4614ee8 --- /dev/null +++ b/docs/discounts/extend_discounts_wizard.md @@ -0,0 +1,19 @@ +--- +description: Integrate custom rules and conditions into the back office forms. +editions: + - lts-update + - commerce +month_change: true +--- + +## Extend Discounts wizard + +To allow using your [custom conditions and rules](extend_discounts.md#create-custom-conditions) by the store managers, you need to integrate them into the back office discounts creation form. + +The [`DiscountFormMapperInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountFormMapperInterface.html) is the service responsible for translating the form data into structures used by the PHP API. + +The form uses a data driver approach, where the mapper provides all the data to the form and the form adjusts and created the fields as neccessary. + +### Condition + +### Rules From 85f4fc9c9cd15d0c698ee853d4c2ae61d4bff06d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Mon, 24 Nov 2025 20:18:23 +0100 Subject: [PATCH 04/16] Current status --- .../src/Command/OrderPriceCommand.php | 134 ++++++++++++++++++ .../PurchaseParityValueFormatter.php | 2 + .../src/Discounts/Step/CustomStep.php | 22 +++ .../Step/CustomStepEventSubscriber.php | 35 +++++ .../Discounts/Step/CustomStepFormListener.php | 37 +++++ .../src/Form/Type/CustomStepType.php | 33 +++++ docs/api/event_reference/discounts_events.md | 2 +- .../order_management/order_management_api.md | 4 + docs/discounts/discounts.md | 5 +- docs/discounts/discounts_api.md | 11 ++ docs/discounts/extend_discounts_wizard.md | 34 ++++- docs/pim/price_api.md | 11 +- 12 files changed, 321 insertions(+), 9 deletions(-) create mode 100644 code_samples/discounts/src/Command/OrderPriceCommand.php create mode 100644 code_samples/discounts/src/Discounts/Step/CustomStep.php create mode 100644 code_samples/discounts/src/Discounts/Step/CustomStepEventSubscriber.php create mode 100644 code_samples/discounts/src/Discounts/Step/CustomStepFormListener.php create mode 100644 code_samples/discounts/src/Form/Type/CustomStepType.php diff --git a/code_samples/discounts/src/Command/OrderPriceCommand.php b/code_samples/discounts/src/Command/OrderPriceCommand.php new file mode 100644 index 0000000000..fe9229a427 --- /dev/null +++ b/code_samples/discounts/src/Command/OrderPriceCommand.php @@ -0,0 +1,134 @@ +permissionResolver = $permissionResolver; + $this->userService = $userService; + $this->productService = $productService; + $this->orderService = $orderService; + $this->productPriceService = $productPriceService; + $this->currencyService = $currencyService; + $this->priceResolver = $priceResolver; + } + + public function execute(InputInterface $input, OutputInterface $output): int + { + $this->permissionResolver->setCurrentUserReference($this->userService->loadUserByLogin('admin')); + + $output->writeln('Product data:'); + $productCode = 'product_code_control_unit_0'; + $orderIdentifier = '4315bc58-1e96-4f21-82a0-15f736cbc4bc'; + $currencyCode = 'EUR'; + + $product = $this->productService->getProduct($productCode); + $currency = $this->currencyService->getCurrencyByCode($currencyCode); + + $base = $this->productPriceService->getPriceByProductAndCurrency($product, $currency); + $resolvedPrice = $this->priceResolver->resolvePrice($product, new PriceContext($currency)); + + if ($resolvedPrice === null) { + throw new Exception('Could not resolve price for the product'); + } + + $output->writeln(sprintf('Base price: %s', $this->formatPrice($base->getMoney()))); + $output->writeln(sprintf('Discounted price: %s', $this->formatPrice($resolvedPrice->getMoney()))); + + if ($resolvedPrice instanceof PriceEnvelopeInterface) { + /** @var \Ibexa\Discounts\Value\Price\Stamp\DiscountStamp $discountStamp */ + foreach ($resolvedPrice->all(DiscountStamp::class) as $discountStamp) { + $output->writeln( + sprintf( + 'Discount applied: %s , new amount: %s', + $discountStamp->getDiscount()->getName(), + $this->formatPrice( + $discountStamp->getNewPrice() + ) + ) + ); + } + } + + $output->writeln('Order details:'); + + $order = $this->orderService->getOrderByIdentifier($orderIdentifier); + foreach ($order->getItems() as $item) { + /** @var ?DiscountsData $discountData */ + $discountData = $item->getContext()['discount_data'] ?? null; + if ($discountData instanceof DiscountsData) { + $output->writeln( + sprintf( + 'Product bought with discount: %s, base price: %s, discounted price: %s', + $item->getProduct()->getName(), + $this->formatPrice($discountData->getOriginalPrice()), + $this->formatPrice( + $item->getValue()->getUnitPriceGross() + ) + ) + ); + } else { + $output->writeln( + sprintf( + 'Product bought with original price: %s, price: %s', + $item->getProduct()->getName(), + $this->formatPrice( + $item->getValue()->getUnitPriceGross() + ) + ) + ); + } + } + + return Command::SUCCESS; + } + + private function formatPrice(Money $money): string + { + return $money->getAmount() / 100.0 . ' ' . $money->getCurrency()->getCode(); + } +} diff --git a/code_samples/discounts/src/Discounts/PurchaseParityValueFormatter.php b/code_samples/discounts/src/Discounts/PurchaseParityValueFormatter.php index a88ffceac5..636e0d372f 100644 --- a/code_samples/discounts/src/Discounts/PurchaseParityValueFormatter.php +++ b/code_samples/discounts/src/Discounts/PurchaseParityValueFormatter.php @@ -12,5 +12,7 @@ final class PurchaseParityValueFormatter implements DiscountValueFormatterInterf { public function format(DiscountInterface $discount, ?Money $money = null): string { + // TODO + return; } } diff --git a/code_samples/discounts/src/Discounts/Step/CustomStep.php b/code_samples/discounts/src/Discounts/Step/CustomStep.php new file mode 100644 index 0000000000..7c0cb9a5f5 --- /dev/null +++ b/code_samples/discounts/src/Discounts/Step/CustomStep.php @@ -0,0 +1,22 @@ +data; + } + + public function setData(?string $data): void + { + $this->data = $data; + } +} diff --git a/code_samples/discounts/src/Discounts/Step/CustomStepEventSubscriber.php b/code_samples/discounts/src/Discounts/Step/CustomStepEventSubscriber.php new file mode 100644 index 0000000000..9a3e337fb0 --- /dev/null +++ b/code_samples/discounts/src/Discounts/Step/CustomStepEventSubscriber.php @@ -0,0 +1,35 @@ + 'onCreateFormDataEvent', + ]; + } + + public function onCreateFormDataEvent(CreateFormDataEvent $event): void + { + $data = $event->getData(); + if ($data->getType() !== DiscountType::CART) { + return; + } + + $event->setData( + $event->getData()->withStep( + new CustomStep(), + CustomStep::IDENTIFIER, + 'Custom step', + -45 + ) + ); + } +} diff --git a/code_samples/discounts/src/Discounts/Step/CustomStepFormListener.php b/code_samples/discounts/src/Discounts/Step/CustomStepFormListener.php new file mode 100644 index 0000000000..a15725c4b5 --- /dev/null +++ b/code_samples/discounts/src/Discounts/Step/CustomStepFormListener.php @@ -0,0 +1,37 @@ +getStepData() instanceof CustomStep; + } + + public function addFields(FormInterface $form, DiscountStepData $data, PreSetDataEvent $event): void + { + $form->add( + 'stepData', + CustomStepType::class, + [ + 'label' => false, + ] + ); + } + + public static function getTranslationMessages(): array + { + return [ + (new Message('discount.step.custom.label', 'discount'))->setDesc('Custom'), + ]; + } +} diff --git a/code_samples/discounts/src/Form/Type/CustomStepType.php b/code_samples/discounts/src/Form/Type/CustomStepType.php new file mode 100644 index 0000000000..c13dcf093f --- /dev/null +++ b/code_samples/discounts/src/Form/Type/CustomStepType.php @@ -0,0 +1,33 @@ +add( + 'data', + TextType::class, + [ + 'label' => 'SAP ID', + 'required' => false, + ] + ); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => CustomStep::class, + ]); + } +} diff --git a/docs/api/event_reference/discounts_events.md b/docs/api/event_reference/discounts_events.md index acfe8c9686..8504da9481 100644 --- a/docs/api/event_reference/discounts_events.md +++ b/docs/api/event_reference/discounts_events.md @@ -28,7 +28,7 @@ The events below are dispatched when managing [`discounts`](discounts_guide.md): ### Form -The events below allow you to customize the discounts creation wizard: +The events below allow you to [customize the discounts creation wizard](extend_discounts_wizard.md). | Event | Dispatched by | |---|---| diff --git a/docs/commerce/order_management/order_management_api.md b/docs/commerce/order_management/order_management_api.md index ed1afc7d6f..b398c3e6f2 100644 --- a/docs/commerce/order_management/order_management_api.md +++ b/docs/commerce/order_management/order_management_api.md @@ -22,6 +22,10 @@ To access a single order by using its string identifier, use the [`OrderServiceI [[= include_file('code_samples/api/commerce/src/Command/OrderCommand.php', 57, 61) =]] ``` +Use the returned [`OrderInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-OrderManagement-Value-Order-OrderInterface.html) value object to access details about the order. + +See the [Discounts API](discounts_api.md#resolving-prices) to learn how to retrieve applied discount details from the order's context. + ### Get single order by ID To access a single order by using its numerical ID, use the [`OrderServiceInterface::getOrder`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-OrderManagement-OrderServiceInterface.html#method_getOrder) method: diff --git a/docs/discounts/discounts.md b/docs/discounts/discounts.md index 1710847cb0..c5a6da9aff 100644 --- a/docs/discounts/discounts.md +++ b/docs/discounts/discounts.md @@ -14,10 +14,11 @@ After you install it, temporary or permanent discounts can be applied against it You can also extend the feature, for example, by creating custom pricing rules, application conditions, or changing discount priorities. +## Getting Started + [[= cards([ "discounts/discounts_guide", "discounts/install_discounts", "discounts/configure_discounts", -"discounts/discounts_api", -"discounts/extend_discounts" ], columns=2) =]] + diff --git a/docs/discounts/discounts_api.md b/docs/discounts/discounts_api.md index 73d6ff368d..cb44a1b49c 100644 --- a/docs/discounts/discounts_api.md +++ b/docs/discounts/discounts_api.md @@ -127,3 +127,14 @@ You can search for Discounts using the [`DiscountServiceInterface::findDiscounts To learn more about the available search options, see Discounts' [Search Criteria](discounts_criteria.md) and [Sort Clauses](discounts_sort_clauses.md). For discount codes, you can query the database for discount code usage using [`DiscountCodeServiceInterface::findCodeUsages()`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-DiscountsCodes-DiscountCodeServiceInterface.html#method_findCodeUsages) and [`DiscountCodeUsageQuery`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-DiscountsCodes-Value-Query-DiscountCodeUsageQuery.html). + +## Resolving prices + +The applied discounts change final product pricing. +To learn more about working with prices, see [Price API](price_api.md#prices). + +The example below shows how you can use: +- [`ProductPriceServiceInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-ProductCatalog-ProductPriceServiceInterface.html) and [`PriceResolverInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-ProductCatalog-PriceResolverInterface.html) to query for product prices +- [`OrderServiceInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-OrderManagement-OrderServiceInterface.html) to display discount detail for orders + +[[= include_file('code_samples/discounts/src/Command/OrderPriceCommand.php') =]] diff --git a/docs/discounts/extend_discounts_wizard.md b/docs/discounts/extend_discounts_wizard.md index 0ec4614ee8..e7daa7d795 100644 --- a/docs/discounts/extend_discounts_wizard.md +++ b/docs/discounts/extend_discounts_wizard.md @@ -12,7 +12,39 @@ To allow using your [custom conditions and rules](extend_discounts.md#create-cus The [`DiscountFormMapperInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountFormMapperInterface.html) is the service responsible for translating the form data into structures used by the PHP API. -The form uses a data driver approach, where the mapper provides all the data to the form and the form adjusts and created the fields as neccessary. +The form uses a data driver approach, where the mapper provides all the data to the form and the form adjusts and creates the fields as neccessary. + +It also provides a two-way mapping between the form structures (used to render the form) and the PHP API values used to create the discounts. + +TODO: DiscountFormMapper zwraca obiekty typu DiscountDataInterface - i on jest kluczowy! + +### Custom form steps + +To add a custom step, create a new event listener listening to the [`CreateFormDataEvent` event](discounts_events.md#form). + +``` php + +``` + +And add the required data class: + +``` php + +``` + +Then, + + +The following priorities are used in the system by default: + +| Step name | Priority | +|---| ---| +| General properties | 50| +| Target group | -20 | +| Products | -30 | +| Conditions | -40 | +| Discount value | -50 | +| Summary | -1000 | ### Condition diff --git a/docs/pim/price_api.md b/docs/pim/price_api.md index 9630453638..40269a3670 100644 --- a/docs/pim/price_api.md +++ b/docs/pim/price_api.md @@ -68,12 +68,13 @@ For example, to create a new price for a given currency, use `ProductPriceServic To display a product price on a product page or in the cart, you must calculate its value based on a base price and the context. Context contains information about any price modifiers that may apply to a specific customer group. -To determine the final price, or resolve the price, use the `PriceResolverInterface` service, which uses the following logic: +To determine the final price, or resolve the price, use the `PriceResolverInterface` service, which takes the following conditions into account: -1. Checks whether a price exists for the product and currency, returns `null` if no such price exists. -2. Verifies whether a customer group-related modifier exists: - 1. If yes, it returns a custom price that is valid for the selected customer group. - 2. If not, it returns a base product price in the selected currency. +1. Existance of base price for the product in the specified currency +2. Existance of customer group-related modifiers +3. Existance of applicable [discounts](discounts.md) + +If the base price in the specified currency is missing, the return value is `null`. To resolve a price of a product in the currency for the current context, use either `PriceResolverInterface::resolvePrice()` or `PriceResolverInterface::resolvePrices()`: From 948c7305aef172957941c7645854d26390d163d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Tue, 25 Nov 2025 21:11:07 +0100 Subject: [PATCH 05/16] Current status --- .../src/Command/OrderPriceCommand.php | 6 +-- docs/api/event_reference/discounts_events.md | 2 + docs/discounts/discounts.md | 15 +++++++ docs/discounts/discounts_api.md | 33 +++++++++------- docs/discounts/extend_discounts_wizard.md | 39 ++++++++++++------- docs/permissions/policies.md | 2 +- docs/pim/price_api.md | 14 +++---- .../discounts_criteria.md | 1 + .../discounts_sort_clauses.md | 1 + 9 files changed, 75 insertions(+), 38 deletions(-) diff --git a/code_samples/discounts/src/Command/OrderPriceCommand.php b/code_samples/discounts/src/Command/OrderPriceCommand.php index fe9229a427..635be4cff1 100644 --- a/code_samples/discounts/src/Command/OrderPriceCommand.php +++ b/code_samples/discounts/src/Command/OrderPriceCommand.php @@ -61,22 +61,22 @@ public function execute(InputInterface $input, OutputInterface $output): int { $this->permissionResolver->setCurrentUserReference($this->userService->loadUserByLogin('admin')); - $output->writeln('Product data:'); $productCode = 'product_code_control_unit_0'; $orderIdentifier = '4315bc58-1e96-4f21-82a0-15f736cbc4bc'; $currencyCode = 'EUR'; + $output->writeln('Product data:'); $product = $this->productService->getProduct($productCode); $currency = $this->currencyService->getCurrencyByCode($currencyCode); - $base = $this->productPriceService->getPriceByProductAndCurrency($product, $currency); + $basePrice = $this->productPriceService->getPriceByProductAndCurrency($product, $currency); $resolvedPrice = $this->priceResolver->resolvePrice($product, new PriceContext($currency)); if ($resolvedPrice === null) { throw new Exception('Could not resolve price for the product'); } - $output->writeln(sprintf('Base price: %s', $this->formatPrice($base->getMoney()))); + $output->writeln(sprintf('Base price: %s', $this->formatPrice($basePrice->getMoney()))); $output->writeln(sprintf('Discounted price: %s', $this->formatPrice($resolvedPrice->getMoney()))); if ($resolvedPrice instanceof PriceEnvelopeInterface) { diff --git a/docs/api/event_reference/discounts_events.md b/docs/api/event_reference/discounts_events.md index 8504da9481..0f614addc4 100644 --- a/docs/api/event_reference/discounts_events.md +++ b/docs/api/event_reference/discounts_events.md @@ -51,6 +51,8 @@ The following events are dispatched when rendering each step of the discount wiz The event classes are shared between steps, but they are dispatched with different names. Each step form mapper dispatches its own set of events. +You can use the names specified above or generate them using the `createEventName` method, for example `CreateFormDataEvent::createEventName(GeneralPropertiesInterface::IDENTIFIER)` + | Form mapper | Step identifier | |---|---| | [`ConditionsMapperInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-Admin-FormMapper-ConditionsMapperInterface.html)| [`conditions`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-Admin-Form-Data-ConditionsInterface.html#constant_IDENTIFIER) | diff --git a/docs/discounts/discounts.md b/docs/discounts/discounts.md index c5a6da9aff..72cc2d50b0 100644 --- a/docs/discounts/discounts.md +++ b/docs/discounts/discounts.md @@ -20,5 +20,20 @@ You can also extend the feature, for example, by creating custom pricing rules, "discounts/discounts_guide", "discounts/install_discounts", "discounts/configure_discounts", +("permissions/policies#discounts", "Policies", "Learn about the available Discounts policies"), +("https://doc.ibexa.co/projects/userguide/en/4.6/commerce/discounts/work_with_discounts/"), ], columns=2) =]] +## Development + +[[= cards([ +"discounts/discounts_api", +"discounts/extend_discounts", +"discounts/extend_discounts_wizard", +"api/event_reference/discounts_events", +("https://doc.ibexa.co/en/4.6/api/rest_api/rest_api_reference/rest_api_reference.html#discounts", "REST API Reference", "See the available endpoints for Discounts"), +"templating/twig_function_reference/discounts_twig_functions", +"search/discounts_search_reference/discounts_criteria", +"search/discounts_search_reference/discounts_sort_clauses", +("content_management/data_migration/importing_data#discounts", "Importing Discounts", "Learn how to manage Discounts using data migrations"), +], columns=4) =]] diff --git a/docs/discounts/discounts_api.md b/docs/discounts/discounts_api.md index cb44a1b49c..d72ddbe614 100644 --- a/docs/discounts/discounts_api.md +++ b/docs/discounts/discounts_api.md @@ -36,12 +36,12 @@ Use the expression values provided below when using data migrations or when pars ### Rules Discount rules define how the calculate the price reduction. -The following discount rule types are available: +The following discount rule types are available in the `\Ibexa\Discounts\Value\DiscountRule` namespace: | Rule type | Identifier | Description | Expression value | |---|---|---|---| -| `Ibexa\Discounts\Value\DiscountRule\FixedAmount` | `fixed_amount` | Deducts the specified amount, for example 10 EUR, from the base price | `discount_amount` | -| `Ibexa\Discounts\Value\DiscountRule\Percentage` | `percentage` | Deducts the specified percentage, for example -10%, from the base price | `discount_percentage` | +| `FixedAmount` | `fixed_amount` | Deducts the specified amount, for example 10 EUR, from the base price | `discount_amount` | +| `Percentage` | `percentage` | Deducts the specified percentage, for example -10%, from the base price | `discount_percentage` | Only a single discount can be applied to a given product, and a discount can only have a single rule. @@ -51,14 +51,14 @@ With conditions you can narrow down the scenarios in which the discount applies. | Condition | Applies to | Identifier | Description | Expression values | |---|---|---|---|---| -| `Ibexa\Discounts\Value\DiscountCondition\IsInCategory` | Cart, Catalog | `is_in_category` | Checks if the product belongs to specified [product categories]([[= user_doc =]]/pim/work_with_product_categories) | `categories` | -| `Ibexa\Discounts\Value\DiscountCondition\IsInCurrency` | Cart, Catalog |`is_in_currency` | Checks if the product has price in the specified currency | `currency_code` | -| `Ibexa\Discounts\Value\DiscountCondition\IsInRegions` | Cart, Catalog | `is_in_regions` | Checks if the customer is making the purchase in one of the specified regions | `regions` | -| `Ibexa\Discounts\Value\DiscountCondition\IsProductInArray` | Cart, Catalog| `is_product_in_array` | Checks if the product belongs to the group of selected products | `product_codes` | -| `Ibexa\Discounts\Value\DiscountCondition\IsUserInCustomerGroup` | Cart, Catalog| `is_user_in_customer_group` | Check if the customer belongs to specified [customer groups](customer_groups.md) | `customer_groups` | -| `Ibexa\Discounts\Value\DiscountCondition\IsProductInQuantityInCart` | Cart | `is_product_in_quantity_in_cart` | Checks if the required minimum quantity of a given product is present in the cart | `quantity` | -| `Ibexa\Discounts\Value\DiscountCondition\MinimumPurchaseAmount` | Cart | `minimum_purchase_amount` | Checks if purchase amount in the cart exceeds the specified minimum | `minimum_purchase_amount` | -| `Ibexa\DiscountsCodes\Value\DiscountCondition\IsValidDiscountCode` | Cart | `is_valid_discount_code` | Checks if the correct discount code has been provided and how many times it was used by the customer | `discount_code`, `usage_count` | +| `IsInCategory` | Cart, Catalog | `is_in_category` | Checks if the product belongs to specified [product categories]([[= user_doc =]]/pim/work_with_product_categories) | `categories` | +| `IsInCurrency` | Cart, Catalog |`is_in_currency` | Checks if the product has price in the specified currency | `currency_code` | +| `IsInRegions` | Cart, Catalog | `is_in_regions` | Checks if the customer is making the purchase in one of the specified regions | `regions` | +| `IsProductInArray` | Cart, Catalog | `is_product_in_array` | Checks if the product belongs to the group of selected products | `product_codes` | +| `IsUserInCustomerGroup` | Cart, Catalog| `is_user_in_customer_group` | Check if the customer belongs to specified [customer groups](customer_groups.md) | `customer_groups` | +| `IsProductInQuantityInCart` | Cart | `is_product_in_quantity_in_cart` | Checks if the required minimum quantity of a given product is present in the cart | `quantity` | +| `MinimumPurchaseAmount` | Cart | `minimum_purchase_amount` | Checks if purchase amount in the cart exceeds the specified minimum | `minimum_purchase_amount` | +| `IsValidDiscountCode` | Cart | `is_valid_discount_code` | Checks if the correct discount code has been provided and how many times it was used by the customer | `discount_code`, `usage_count` | When multiple conditions are specified, all of them must be met. @@ -128,13 +128,18 @@ To learn more about the available search options, see Discounts' [Search Criteri For discount codes, you can query the database for discount code usage using [`DiscountCodeServiceInterface::findCodeUsages()`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-DiscountsCodes-DiscountCodeServiceInterface.html#method_findCodeUsages) and [`DiscountCodeUsageQuery`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-DiscountsCodes-Value-Query-DiscountCodeUsageQuery.html). -## Resolving prices +## Retrieve applied discounts The applied discounts change final product pricing. To learn more about working with prices, see [Price API](price_api.md#prices). The example below shows how you can use: -- [`ProductPriceServiceInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-ProductCatalog-ProductPriceServiceInterface.html) and [`PriceResolverInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-ProductCatalog-PriceResolverInterface.html) to query for product prices -- [`OrderServiceInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-OrderManagement-OrderServiceInterface.html) to display discount detail for orders +- [`ProductPriceServiceInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-ProductCatalog-ProductPriceServiceInterface.html) to query for base product prices +- [`PriceResolverInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-ProductCatalog-PriceResolverInterface.html) to query for final product prices +- [`PriceEnvelopeInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-ProductCatalog-Values-Price-PriceEnvelopeInterface.html) to retrieve applied discounts +- [`OrderServiceInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-OrderManagement-OrderServiceInterface.html) to display discount details for [orders](order_management.md) + +``` php hl_lines="72-73 79-80 82-95 99-125" [[= include_file('code_samples/discounts/src/Command/OrderPriceCommand.php') =]] +``` diff --git a/docs/discounts/extend_discounts_wizard.md b/docs/discounts/extend_discounts_wizard.md index e7daa7d795..68664b0231 100644 --- a/docs/discounts/extend_discounts_wizard.md +++ b/docs/discounts/extend_discounts_wizard.md @@ -6,36 +6,32 @@ editions: month_change: true --- -## Extend Discounts wizard +# Extend Discounts wizard To allow using your [custom conditions and rules](extend_discounts.md#create-custom-conditions) by the store managers, you need to integrate them into the back office discounts creation form. The [`DiscountFormMapperInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountFormMapperInterface.html) is the service responsible for translating the form data into structures used by the PHP API. -The form uses a data driver approach, where the mapper provides all the data to the form and the form adjusts and creates the fields as neccessary. +The form uses a data driven approach, where the mapper provides all the data to the form and the form creates the fields. It also provides a two-way mapping between the form structures (used to render the form) and the PHP API values used to create the discounts. -TODO: DiscountFormMapper zwraca obiekty typu DiscountDataInterface - i on jest kluczowy! +The `DiscountFormMapperInterface::createFormData()` and `DiscountFormMapperInterface::publicmapDiscountToFormData()` methods return objects implementing the [`DiscountDataInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-Admin-Form-Data-DiscountDataInterface.html), allowing you to access the form data. -### Custom form steps - -To add a custom step, create a new event listener listening to the [`CreateFormDataEvent` event](discounts_events.md#form). +The `DiscountFormMapperInterface::mapCreateDataToStruct()`, `DiscountFormMapperInterface::mapEditTranslateDataToStruct()`, and `mapUpdateDataToStruct()` -``` php +Form mappers attached both to the whole wizard and to each step in it emit [events](discounts_events.md#forms), allowing you to customize their behavior. -``` +### Custom form steps -And add the required data class: +To add a custom step, create a new event listener listening to the [`CreateFormDataEvent` event](discounts_events.md#form). +The example below adds a new step to the cart discount wizard. ``` php ``` -Then, - - -The following priorities are used in the system by default: +Each of the existing form steps has a constant priority, allowing you to add your custom step between them. | Step name | Priority | |---| ---| @@ -46,6 +42,23 @@ The following priorities are used in the system by default: | Discount value | -50 | | Summary | -1000 | +The priority of `-45` causes the custom step to be rendered between the "Conditions" and "Discount value" steps. + +Add the required data class: + +``` php + +``` + +Then, add a form mapper + +And a form type dedicated for the created data class: + + +The following priorities are used in the system by default: + + + ### Condition ### Rules diff --git a/docs/permissions/policies.md b/docs/permissions/policies.md index b651676f44..3764b0905d 100644 --- a/docs/permissions/policies.md +++ b/docs/permissions/policies.md @@ -127,7 +127,7 @@ Each role you assign to user or user group consists of policies which define, wh #### Discounts [[% include 'snippets/lts-update_badge.md' %]] [[% include 'snippets/commerce_badge.md' %]] -The discount policies decide which actions can be executed by given user or user group. +The [discount](discounts.md) policies decide which actions can be executed by given user or user group. !!! caution "Customers and discount policies" diff --git a/docs/pim/price_api.md b/docs/pim/price_api.md index 40269a3670..58681bafff 100644 --- a/docs/pim/price_api.md +++ b/docs/pim/price_api.md @@ -6,7 +6,7 @@ description: Use PHP API to manage currencies in the shop and product prices. ## Currencies -To manage currencies, use `CurrencyServiceInterface`. +To manage currencies, use [`CurrencyServiceInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-ProductCatalog-CurrencyServiceInterface.html). To access a currency object by its code, use `CurrencyServiceInterface::getCurrencyByCode`. To access a whole list of currencies, use `CurrencyServiceInterface::findCurrencies`. @@ -23,7 +23,7 @@ To create a new currency, use `CurrencyServiceInterface::createCurrency()` and p ## Prices -To manage prices, use `ProductPriceService`. +To manage prices, use [`ProductPriceServiceInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-ProductCatalog-ProductPriceServiceInterface.html). To retrieve the price of a product in the currency for the current context, use `Product::getPrice()`: @@ -37,7 +37,7 @@ To retrieve the price of a product in a specific currency, use `ProductPriceServ [[= include_file('code_samples/api/product_catalog/src/Command/ProductPriceCommand.php', 79, 82) =]] ``` -To get all prices (in different currencies) for a given product, use `ProductPriceService::findPricesByProductCode`: +To get all prices (in different currencies) for a given product, use `ProductPriceServiceInterface::findPricesByProductCode`: ``` php [[= include_file('code_samples/api/product_catalog/src/Command/ProductPriceCommand.php', 93, 99) =]] @@ -51,7 +51,7 @@ To load price definitions that match given criteria, use `ProductPriceServiceInt [[= include_file('code_samples/api/product_catalog/src/Command/ProductPriceCommand.php', 100, 110) =]] ``` -You can also use `ProductPriceService` to create or modify existing prices. +You can also use `ProductPriceServiceInterface` to create or modify existing prices. For example, to create a new price for a given currency, use `ProductPriceService::createProductPrice` and provide it with a `ProductPriceCreateStruct` object: ``` php @@ -68,7 +68,7 @@ For example, to create a new price for a given currency, use `ProductPriceServic To display a product price on a product page or in the cart, you must calculate its value based on a base price and the context. Context contains information about any price modifiers that may apply to a specific customer group. -To determine the final price, or resolve the price, use the `PriceResolverInterface` service, which takes the following conditions into account: +To determine the final price, or resolve the price, use the [`PriceResolverInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-ProductCatalog-PriceResolverInterface.html) service, which takes the following conditions into account: 1. Existance of base price for the product in the specified currency 2. Existance of customer group-related modifiers @@ -86,8 +86,8 @@ To resolve a price of a product in the currency for the current context, use eit ## VAT -To get information about the VAT categories and rates configured in the system, use `VatServiceInterface`. -VAT is configured per region, so you also need to use `RegionServiceInterface` to get the relevant region object. +To get information about the VAT categories and rates configured in the system, use [`VatServiceInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-ProductCatalog-VatServiceInterface.html). +VAT is configured per region, so you also need to use [`RegionServiceInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-ProductCatalog-RegionServiceInterface.html) to get the relevant region object. ``` php [[= include_file('code_samples/api/product_catalog/src/Command/VatCommand.php', 49, 50) =]] diff --git a/docs/search/discounts_search_reference/discounts_criteria.md b/docs/search/discounts_search_reference/discounts_criteria.md index 87631d8597..94a422305f 100644 --- a/docs/search/discounts_search_reference/discounts_criteria.md +++ b/docs/search/discounts_search_reference/discounts_criteria.md @@ -3,6 +3,7 @@ month_change: false editions: - lts-update - commerce +description: Search Criteria available for Discounts search --- # Discounts Search Criterion reference diff --git a/docs/search/discounts_search_reference/discounts_sort_clauses.md b/docs/search/discounts_search_reference/discounts_sort_clauses.md index 148f8e5df8..585cdead1d 100644 --- a/docs/search/discounts_search_reference/discounts_sort_clauses.md +++ b/docs/search/discounts_search_reference/discounts_sort_clauses.md @@ -3,6 +3,7 @@ month_change: false editions: - lts-update - commerce +description: Sort Clauses available for Discounts search --- # Discounts Search Sort Clauses reference From 80a725a429dcf30aae0bf41162e571e93d5573fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Tue, 25 Nov 2025 22:28:30 +0100 Subject: [PATCH 06/16] PHPStan is passing --- .../Condition/IsAccountAnniversary.php | 33 +++++++ .../IsAccountAnniversaryConditionFactory.php | 16 ++++ .../CurrentUserRegistrationDateResolver.php | 32 +++++++ .../IsAnniversaryResolver.php | 42 ++++++++ .../PurchaseParityValueFormatter.php | 18 ---- .../RecentDiscountPrioritizationStrategy.php | 24 +++++ .../Rule/PurchaseParityValueFormatter.php | 17 ++++ .../Rule/PurchasingPowerParityRule.php | 44 +++++++++ .../Rule/PurchasingPowerParityRuleFactory.php | 14 +++ docs/api/event_reference/discounts_events.md | 4 +- docs/discounts/discounts_api.md | 2 +- docs/discounts/extend_discounts.md | 95 +++++++++++++------ docs/discounts/extend_discounts_wizard.md | 31 ++++-- .../discounts_criteria.md | 2 +- mkdocs.yml | 1 + 15 files changed, 313 insertions(+), 62 deletions(-) create mode 100644 code_samples/discounts/src/Discounts/Condition/IsAccountAnniversary.php create mode 100644 code_samples/discounts/src/Discounts/Condition/IsAccountAnniversaryConditionFactory.php create mode 100644 code_samples/discounts/src/Discounts/ExpressionProvider/CurrentUserRegistrationDateResolver.php create mode 100644 code_samples/discounts/src/Discounts/ExpressionProvider/IsAnniversaryResolver.php delete mode 100644 code_samples/discounts/src/Discounts/PurchaseParityValueFormatter.php create mode 100644 code_samples/discounts/src/Discounts/RecentDiscountPrioritizationStrategy.php create mode 100644 code_samples/discounts/src/Discounts/Rule/PurchaseParityValueFormatter.php create mode 100644 code_samples/discounts/src/Discounts/Rule/PurchasingPowerParityRule.php create mode 100644 code_samples/discounts/src/Discounts/Rule/PurchasingPowerParityRuleFactory.php diff --git a/code_samples/discounts/src/Discounts/Condition/IsAccountAnniversary.php b/code_samples/discounts/src/Discounts/Condition/IsAccountAnniversary.php new file mode 100644 index 0000000000..66c442352a --- /dev/null +++ b/code_samples/discounts/src/Discounts/Condition/IsAccountAnniversary.php @@ -0,0 +1,33 @@ + $tolerance ?? 0, + ]); + } + + public function getTolerance(): int + { + return $this->getExpressionValue('tolerance'); + } + + public function getIdentifier(): string + { + return self::IDENTIFIER; + } + + public function getExpression(): string + { + return 'is_anniversary(current_user_registration_date, tolerance)'; + } +} diff --git a/code_samples/discounts/src/Discounts/Condition/IsAccountAnniversaryConditionFactory.php b/code_samples/discounts/src/Discounts/Condition/IsAccountAnniversaryConditionFactory.php new file mode 100644 index 0000000000..4d2ede841e --- /dev/null +++ b/code_samples/discounts/src/Discounts/Condition/IsAccountAnniversaryConditionFactory.php @@ -0,0 +1,16 @@ +permissionResolver = $permissionResolver; + $this->userService = $userService; + } + + /** @return array{current_user_registration_date: \DateTimeInterface} + */ + public function getVariables(PriceContextInterface $priceContext): array + { + return [ + 'current_user_registration_date' => $this->userService->loadUser( + $this->permissionResolver->getCurrentUserReference()->getUserId() + )->getContentInfo()->publishedDate, + ]; + } +} diff --git a/code_samples/discounts/src/Discounts/ExpressionProvider/IsAnniversaryResolver.php b/code_samples/discounts/src/Discounts/ExpressionProvider/IsAnniversaryResolver.php new file mode 100644 index 0000000000..7c071d2f05 --- /dev/null +++ b/code_samples/discounts/src/Discounts/ExpressionProvider/IsAnniversaryResolver.php @@ -0,0 +1,42 @@ +unifyYear(new DateTimeImmutable()); + $d2 = $this->unifyYear($date); + + $diff = $d1->diff($d2, true)->days; + + // Check if the difference between dates is within the tolerance + return $diff <= $tolerance; + } + + private function unifyYear(DateTimeInterface $date): DateTimeImmutable + { + // Create a new date using the reference year but with the same month and day + $date = DateTimeImmutable::createFromFormat( + self::YEAR_MONTH_DAY_FORMAT, + self::REFERENCE_YEAR . '-' . $date->format(self::MONTH_DAY_FORMAT) + ); + + if ($date === false) { + throw new \RuntimeException('Failed to unify year for date.'); + } + + return $date; + } +} diff --git a/code_samples/discounts/src/Discounts/PurchaseParityValueFormatter.php b/code_samples/discounts/src/Discounts/PurchaseParityValueFormatter.php deleted file mode 100644 index 636e0d372f..0000000000 --- a/code_samples/discounts/src/Discounts/PurchaseParityValueFormatter.php +++ /dev/null @@ -1,18 +0,0 @@ -inner = $inner; + } + + public function getOrder(): array + { + return array_merge( + [new UpdatedAt()], + $this->inner->getOrder() + ); + } +} diff --git a/code_samples/discounts/src/Discounts/Rule/PurchaseParityValueFormatter.php b/code_samples/discounts/src/Discounts/Rule/PurchaseParityValueFormatter.php new file mode 100644 index 0000000000..b569b187cb --- /dev/null +++ b/code_samples/discounts/src/Discounts/Rule/PurchaseParityValueFormatter.php @@ -0,0 +1,17 @@ + 100, + 'germany' => 81.6, + 'france' => 80, + 'spain' => 69, + ]; + + /** @param ?array $powerParityMap */ + public function __construct(?array $powerParityMap = null) + { + parent::__construct( + [ + 'power_parity_map' => $powerParityMap ?? self::DEFAULT_PARITY_MAP, + ] + ); + } + + /** @return array */ + public function getMap(): array + { + return $this->getExpressionValue('power_parity_map'); + } + + public function getExpression(): string + { + return 'amount * (power_parity_map[get_current_region().getIdentifier()] / power_parity_map["default"])'; + } + + public function getType(): string + { + return self::TYPE; + } +} diff --git a/code_samples/discounts/src/Discounts/Rule/PurchasingPowerParityRuleFactory.php b/code_samples/discounts/src/Discounts/Rule/PurchasingPowerParityRuleFactory.php new file mode 100644 index 0000000000..a173345d01 --- /dev/null +++ b/code_samples/discounts/src/Discounts/Rule/PurchasingPowerParityRuleFactory.php @@ -0,0 +1,14 @@ + Date: Wed, 26 Nov 2025 00:15:04 +0100 Subject: [PATCH 07/16] Conditions ready --- .../IsAnniversaryResolver.php | 2 +- .../Step/AnniversaryConditionStep.php | 20 +++++ ...nniversaryConditionStepEventSubscriber.php | 71 ++++++++++++++++++ ... AnniversaryConditionStepFormListener.php} | 8 +- .../src/Discounts/Step/CustomStep.php | 22 ------ .../Step/CustomStepEventSubscriber.php | 35 --------- .../Type/AnniversaryConditionStepType.php | 44 +++++++++++ .../src/Form/Type/CustomStepType.php | 33 --------- docs/api/event_reference/discounts_events.md | 2 +- docs/discounts/extend_discounts.md | 4 +- docs/discounts/extend_discounts_wizard.md | 74 +++++++++++++------ 11 files changed, 193 insertions(+), 122 deletions(-) create mode 100644 code_samples/discounts/src/Discounts/Step/AnniversaryConditionStep.php create mode 100644 code_samples/discounts/src/Discounts/Step/AnniversaryConditionStepEventSubscriber.php rename code_samples/discounts/src/Discounts/Step/{CustomStepFormListener.php => AnniversaryConditionStepFormListener.php} (75%) delete mode 100644 code_samples/discounts/src/Discounts/Step/CustomStep.php delete mode 100644 code_samples/discounts/src/Discounts/Step/CustomStepEventSubscriber.php create mode 100644 code_samples/discounts/src/Form/Type/AnniversaryConditionStepType.php delete mode 100644 code_samples/discounts/src/Form/Type/CustomStepType.php diff --git a/code_samples/discounts/src/Discounts/ExpressionProvider/IsAnniversaryResolver.php b/code_samples/discounts/src/Discounts/ExpressionProvider/IsAnniversaryResolver.php index 7c071d2f05..a430f6dd51 100644 --- a/code_samples/discounts/src/Discounts/ExpressionProvider/IsAnniversaryResolver.php +++ b/code_samples/discounts/src/Discounts/ExpressionProvider/IsAnniversaryResolver.php @@ -18,7 +18,7 @@ public function __invoke(DateTimeInterface $date, int $tolerance = 0): bool { $d1 = $this->unifyYear(new DateTimeImmutable()); $d2 = $this->unifyYear($date); - + $diff = $d1->diff($d2, true)->days; // Check if the difference between dates is within the tolerance diff --git a/code_samples/discounts/src/Discounts/Step/AnniversaryConditionStep.php b/code_samples/discounts/src/Discounts/Step/AnniversaryConditionStep.php new file mode 100644 index 0000000000..bfe53f0b1f --- /dev/null +++ b/code_samples/discounts/src/Discounts/Step/AnniversaryConditionStep.php @@ -0,0 +1,20 @@ +enabled = $enabled; + $this->tolerance = $tolerance; + } +} diff --git a/code_samples/discounts/src/Discounts/Step/AnniversaryConditionStepEventSubscriber.php b/code_samples/discounts/src/Discounts/Step/AnniversaryConditionStepEventSubscriber.php new file mode 100644 index 0000000000..8f60118ad1 --- /dev/null +++ b/code_samples/discounts/src/Discounts/Step/AnniversaryConditionStepEventSubscriber.php @@ -0,0 +1,71 @@ + 'addAnniversaryConditionStep', + MapDiscountToFormDataEvent::class => 'addAnniversaryConditionStep', + CreateDiscountCreateStructEvent::class => 'addStepDataToStruct', + CreateDiscountUpdateStructEvent::class => 'addStepDataToStruct', + ]; + } + + /** + * @param \Ibexa\Contracts\Discounts\Event\CreateFormDataEvent|\Ibexa\Contracts\Discounts\Event\MapDiscountToFormDataEvent $event + */ + public function addAnniversaryConditionStep(Event $event): void + { + $data = $event->getData(); + if ($data->getType() !== DiscountType::CART) { + return; + } + + /** @var \App\Discounts\Condition\IsAccountAnniversary $discount */ + $discount = $event instanceof MapDiscountToFormDataEvent ? + $event->getDiscount()->getConditionByIdentifier(IsAccountAnniversary::IDENTIFIER) : + null; + + $conditionStep = $discount !== null ? + new AnniversaryConditionStep(true, $discount->getTolerance()) : + new AnniversaryConditionStep(); + + $event->setData( + $event->getData()->withStep( + $conditionStep, + AnniversaryConditionStep::IDENTIFIER, + 'Anniversary Condition', + -45 // Priority + ) + ); + } + + public function addStepDataToStruct(DiscountStructEventInterface $event): void + { + /** @var AnniversaryConditionStep $stepData */ + $stepData = $event + ->getData() + ->getStepByIdentifier(AnniversaryConditionStep::IDENTIFIER)?->getStepData(); + + if ($stepData === null || !$stepData->enabled) { + return; + } + + $discountStruct = $event->getStruct(); + $discountStruct->addCondition(new IsAccountAnniversary($stepData->tolerance)); + } +} diff --git a/code_samples/discounts/src/Discounts/Step/CustomStepFormListener.php b/code_samples/discounts/src/Discounts/Step/AnniversaryConditionStepFormListener.php similarity index 75% rename from code_samples/discounts/src/Discounts/Step/CustomStepFormListener.php rename to code_samples/discounts/src/Discounts/Step/AnniversaryConditionStepFormListener.php index a15725c4b5..0d23b7f7e9 100644 --- a/code_samples/discounts/src/Discounts/Step/CustomStepFormListener.php +++ b/code_samples/discounts/src/Discounts/Step/AnniversaryConditionStepFormListener.php @@ -2,7 +2,7 @@ namespace App\Discounts\Step; -use App\Form\Type\CustomStepType; +use App\Form\Type\AnniversaryConditionStepType; use Ibexa\Contracts\Discounts\Admin\Form\Data\DiscountStepData; use Ibexa\Contracts\Discounts\Admin\Form\Listener\AbstractStepFormListener; use JMS\TranslationBundle\Model\Message; @@ -10,18 +10,18 @@ use Symfony\Component\Form\Event\PreSetDataEvent; use Symfony\Component\Form\FormInterface; -final class CustomStepFormListener extends AbstractStepFormListener implements TranslationContainerInterface +final class AnniversaryConditionStepFormListener extends AbstractStepFormListener implements TranslationContainerInterface { public function isDataSupported(DiscountStepData $data): bool { - return $data->getStepData() instanceof CustomStep; + return $data->getStepData() instanceof AnniversaryConditionStep; } public function addFields(FormInterface $form, DiscountStepData $data, PreSetDataEvent $event): void { $form->add( 'stepData', - CustomStepType::class, + AnniversaryConditionStepType::class, [ 'label' => false, ] diff --git a/code_samples/discounts/src/Discounts/Step/CustomStep.php b/code_samples/discounts/src/Discounts/Step/CustomStep.php deleted file mode 100644 index 7c0cb9a5f5..0000000000 --- a/code_samples/discounts/src/Discounts/Step/CustomStep.php +++ /dev/null @@ -1,22 +0,0 @@ -data; - } - - public function setData(?string $data): void - { - $this->data = $data; - } -} diff --git a/code_samples/discounts/src/Discounts/Step/CustomStepEventSubscriber.php b/code_samples/discounts/src/Discounts/Step/CustomStepEventSubscriber.php deleted file mode 100644 index 9a3e337fb0..0000000000 --- a/code_samples/discounts/src/Discounts/Step/CustomStepEventSubscriber.php +++ /dev/null @@ -1,35 +0,0 @@ - 'onCreateFormDataEvent', - ]; - } - - public function onCreateFormDataEvent(CreateFormDataEvent $event): void - { - $data = $event->getData(); - if ($data->getType() !== DiscountType::CART) { - return; - } - - $event->setData( - $event->getData()->withStep( - new CustomStep(), - CustomStep::IDENTIFIER, - 'Custom step', - -45 - ) - ); - } -} diff --git a/code_samples/discounts/src/Form/Type/AnniversaryConditionStepType.php b/code_samples/discounts/src/Form/Type/AnniversaryConditionStepType.php new file mode 100644 index 0000000000..004d1d1a7f --- /dev/null +++ b/code_samples/discounts/src/Form/Type/AnniversaryConditionStepType.php @@ -0,0 +1,44 @@ + + */ +final class AnniversaryConditionStepType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add( + 'enabled', + CheckboxType::class, + [ + 'label' => 'Enable anniversary discount', + 'required' => false, + ] + )->add( + 'tolerance', + NumberType::class, + [ + 'label' => 'Tolerance in days', + 'required' => false, + ] + ); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => AnniversaryConditionStep::class, + ]); + } +} diff --git a/code_samples/discounts/src/Form/Type/CustomStepType.php b/code_samples/discounts/src/Form/Type/CustomStepType.php deleted file mode 100644 index c13dcf093f..0000000000 --- a/code_samples/discounts/src/Form/Type/CustomStepType.php +++ /dev/null @@ -1,33 +0,0 @@ -add( - 'data', - TextType::class, - [ - 'label' => 'SAP ID', - 'required' => false, - ] - ); - } - - public function configureOptions(OptionsResolver $resolver): void - { - $resolver->setDefaults([ - 'data_class' => CustomStep::class, - ]); - } -} diff --git a/docs/api/event_reference/discounts_events.md b/docs/api/event_reference/discounts_events.md index d26ad747e6..b0eec55ca1 100644 --- a/docs/api/event_reference/discounts_events.md +++ b/docs/api/event_reference/discounts_events.md @@ -75,4 +75,4 @@ The event below allows you to inject your custom logic before the discount code | Event | Dispatched by | Description | |---|---|---| -|[`BeforeDiscountCodeApplyEvent`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-DiscountsCodes-Event-BeforeDiscountCodeApplyEvent.html)|`Ibexa\Bundle\DiscountsCodes\Controller\REST\DiscountCodeController`| Dispatched before a discount code is applied in the cart | +|[`BeforeDiscountCodeApplyEvent`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-DiscountsCodes-Event-BeforeDiscountCodeApplyEvent.html)|`Ibexa\Bundle\DiscountsCodes\Controller\REST\DiscountCodeController`| Dispatched before a discount code is applied in the cart | diff --git a/docs/discounts/extend_discounts.md b/docs/discounts/extend_discounts.md index a52ca913f6..d8226041ed 100644 --- a/docs/discounts/extend_discounts.md +++ b/docs/discounts/extend_discounts.md @@ -86,9 +86,9 @@ Two new expressions are now available for use in custom conditions and rules. ### Implement custom condition -The following example creates a new discount condition. It allows you to offer a special discount for customers on the date when their account was created. +The following example creates a new discount condition. It allows you to offer a special discount for customers on the date when their account was created, making use of the expressions added above. -The `tolerance` option allows you to make the discount usable for a slighlty longer period of time (for example, a day before or after the registration date) to allow more time for the customers to use it. +The `tolerance` option allows you to make the discount usable for a longer period of time (for example, a day before or after the registration date) to allow more time for the customers to use it. Create the condition by creating a class implementing the [`DiscountConditionInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-Value-DiscountConditionInterface.html): diff --git a/docs/discounts/extend_discounts_wizard.md b/docs/discounts/extend_discounts_wizard.md index 6e21d80d8f..3f1bed50b7 100644 --- a/docs/discounts/extend_discounts_wizard.md +++ b/docs/discounts/extend_discounts_wizard.md @@ -8,12 +8,13 @@ month_change: true # Extend Discounts wizard -To allow the store managers to use your [custom conditions and rules](extend_discounts.md#create-custom-conditions), you need to integrate them into the back office discounts creation form. +## Introduction -The form is built using [Symfony Forms]([[= symfony_doc=]]/forms.html) and the [`DiscountFormMapperInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountFormMapperInterface.html) is the core of the implementation. +For the store managers to use your [custom conditions and rules](extend_discounts.md#create-custom-conditions), you need to integrate them into the back office discounts creation form. -It also provides a two-way mapping between the form structures (used to render the form) and the PHP API values used to create the discounts. -It offers methods related to: +This form is built using [Symfony Forms]([[= symfony_doc=]]/forms.html) and the [`DiscountFormMapperInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountFormMapperInterface.html) is the core of the implementation. + +It provides a two-way mapping between the form structures (used to render the form) and the PHP API values used to create the discounts by offering methods related to: - form rendering - data structure mapping @@ -21,30 +22,46 @@ It offers methods related to: Form rendering methods return objects implementing the [`DiscountDataInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-Admin-Form-Data-DiscountDataInterface.html), allowing you to access and modify the form data. They include: -- [`createFormData()`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountFormMapperInterface.html#method_createFormData) renders the form before the discount exists -- [`mapDiscountToFormData()`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountFormMapperInterface.html#method_mapDiscountToFormData) renders the form when the discount already exists. It fills the discount edit form with the saved discount details. - -The data mapping methods are responsible for transforming the form data into structures compatible to use with the [Discount's PHP API](discounts_api.md). They include: +- [`createFormData()`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountFormMapperInterface.html#method_createFormData) renders the form before the discount is created +- [`mapDiscountToFormData()`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountFormMapperInterface.html#method_mapDiscountToFormData) renders the form when the discount already exists. It fills the discount edit form with the saved discount details -- [`mapCreateDataToStruct()`] -- [`mapEditTranslateDataToStruct()`] -- [`mapUpdateDataToStruct()`] +The data mapping methods are responsible for transforming the form data into structures compatible with the [Discount's PHP API](discounts_api.md) services like [`DiscountServiceInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountServiceInterface.html) and [`DiscountCodeServiceInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-DiscountsCodes-DiscountCodeServiceInterface.html). +They include: +- [`mapCreateDataToStruct()`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountFormMapperInterface.html#method_mapCreateDataToStruct) creates the [`DiscountCreateStruct`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-Value-Struct-DiscountCreateStruct.html) object to create the discount +- [`mapUpdateDataToStruct()`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountFormMapperInterface.html#method_mapUpdateDataToStruct) creates the [`DiscountUpdateStruct`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-Value-Struct-DiscountUpdateStruct.html) object to update the discount +- [`mapEditTranslateDataToStruct()`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountFormMapperInterface.html#method_mapEditTranslateDataToStruct) creates the [`TranslationStruct`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-Value-Struct-DiscountTranslationStruct.html) objects for [translating the discounts](discounts_api.md#discount-translations) -The `mapCreateDataToStruct()`, `DiscountFormMapperInterface::mapEditTranslateDataToStruct()`, and `mapUpdateDataToStruct()` +In addition to these methods, the main form mapper and the form mappers responsible for each step in the wizard dispatch events that you can use to add your custom logic. +See [discount's form events](discounts_events.md#form-events) for a list of the available events. -Form mappers attached both to the whole wizard and to each step in it emit [events](discounts_events.md#forms), allowing you to customize their behavior. +## Integrate custom conditions -### Custom form steps +This example continues the [anniversary discount condition example](extend_discounts.md#implement-custom-condition), integrating the condition with the wizard by adding a dedicated step with condition options. +The condition will be limited to cart discounts only. -To add a custom step, create a new event listener listening to the [`CreateFormDataEvent` event](discounts_events.md#form). -The example below adds a new step to the cart discount wizard. +To add a custom step, create a value object representing the step. +It contains the step identifier, properties for storing form data, and extends the [`AbstractDiscountStep`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-Admin-Form-Data-AbstractDiscountStep.html): ``` php +[[= include_file('code_samples/discounts/src/Discounts/Step/AnniversaryConditionStep.php') =]] +``` + +Then, create a new event listener listening to the [`CreateFormDataEvent` and `MapDiscountToFormDataEvent` events](discounts_events.md#form): +``` php hl_lines="21-22 31-55" +[[= include_file('code_samples/discounts/src/Discounts/Step/AnniversaryConditionStepEventSubscriber.php') =]] ``` -Each of the existing form steps has a constant priority, allowing you to add your custom step between them. +Attaching the `addAnniversaryConditionStep()` method to both these events adds the custom step both in discount creation and edit forms. + +The method first verifies if the form renders the cart discount wizard, according to assumptions of this example. + +Then, it creates the `AnniversaryConditionStep` object. +If the discount existed already and is being edited, the saved values are used to populate the form. + +Finally, the new step is added to the wizard using the `withStep()` method, using `45` as step priority. +Each of the existing form steps has its own priority, allowing you to add your custom steps between them. | Step name | Priority | |---| ---| @@ -55,21 +72,30 @@ Each of the existing form steps has a constant priority, allowing you to add you | Discount value | -50 | | Summary | -1000 | -The priority of `-45` causes the custom step to be rendered between the "Conditions" and "Discount value" steps. +The custom step is added between the "Conditions" and "Discount value" steps. -Add the required data class: +To add form fields to it, create an event listener adding your fields and a custom form type: ``` php +[[= include_file('code_samples/discounts/src/Discounts/Step/AnniversaryConditionStepFormListener.php') =]] +``` +``` php +[[= include_file('code_samples/discounts/src/Form/Type/AnniversaryConditionStepType.php') =]] ``` -Then, add a form mapper +The new form step, including its form fields, are now part of the discounts wizard. + +The last task is making sure that the form data is correctly saved by attaching it to the discounts API structs. -And a form type dedicated for the created data class: +The previously created `addStepDataToStruct()` method in `AnniversaryConditionStepEventSubscriber` is responsible for it: +``` php hl_lines="23-24 57-70" +[[= include_file('code_samples/discounts/src/Discounts/Step/AnniversaryConditionStepEventSubscriber.php') =]] +``` -The following priorities are used in the system by default: +When the form is submitted, this method extracts information whether the store manager enabled the anniversary discount in the form and adds the condition to make sure this data is properly saved. -### Custom condition +The custom condition is now integrated with the discounts wizard and can be used by store managers to attract new customers. -### Custom rules +## Integrate custom rules From 8b2e417b09e31332f9afe9290775ea484eb803b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Wed, 26 Nov 2025 09:15:53 +0100 Subject: [PATCH 08/16] Conditions refactored --- ...nniversaryConditionStepEventSubscriber.php | 51 +++++++++++++ ...nniversaryConditionStepEventSubscriber.php | 0 .../Form/Data/PurchasingPowerParityValue.php | 9 +++ ...PowerParityDiscountValueFormTypeMapper.php | 26 +++++++ .../PurchasingPowerParityFormMapper.php | 31 ++++++++ .../PurchasingPowerParityValueMapper.php | 62 +++++++++++++++ .../PurchasingPowerParityValueType.php | 75 +++++++++++++++++++ docs/discounts/extend_discounts.md | 4 +- docs/discounts/extend_discounts_wizard.md | 17 ++++- 9 files changed, 269 insertions(+), 6 deletions(-) create mode 100644 code_samples/discounts/src/Discounts/Step/Step1/AnniversaryConditionStepEventSubscriber.php rename code_samples/discounts/src/Discounts/Step/{ => Step2}/AnniversaryConditionStepEventSubscriber.php (100%) create mode 100644 code_samples/discounts/src/Form/Data/PurchasingPowerParityValue.php create mode 100644 code_samples/discounts/src/Form/FormMapper/PurchasingPowerParityDiscountValueFormTypeMapper.php create mode 100644 code_samples/discounts/src/Form/FormMapper/PurchasingPowerParityFormMapper.php create mode 100644 code_samples/discounts/src/Form/FormMapper/PurchasingPowerParityValueMapper.php create mode 100644 code_samples/discounts/src/Form/Type/DiscountValue/PurchasingPowerParityValueType.php diff --git a/code_samples/discounts/src/Discounts/Step/Step1/AnniversaryConditionStepEventSubscriber.php b/code_samples/discounts/src/Discounts/Step/Step1/AnniversaryConditionStepEventSubscriber.php new file mode 100644 index 0000000000..8765383d45 --- /dev/null +++ b/code_samples/discounts/src/Discounts/Step/Step1/AnniversaryConditionStepEventSubscriber.php @@ -0,0 +1,51 @@ + 'addAnniversaryConditionStep', + MapDiscountToFormDataEvent::class => 'addAnniversaryConditionStep', + ]; + } + + /** + * @param \Ibexa\Contracts\Discounts\Event\CreateFormDataEvent|\Ibexa\Contracts\Discounts\Event\MapDiscountToFormDataEvent $event + */ + public function addAnniversaryConditionStep(Event $event): void + { + $data = $event->getData(); + if ($data->getType() !== DiscountType::CART) { + return; + } + + /** @var \App\Discounts\Condition\IsAccountAnniversary $discount */ + $discount = $event instanceof MapDiscountToFormDataEvent ? + $event->getDiscount()->getConditionByIdentifier(IsAccountAnniversary::IDENTIFIER) : + null; + + $conditionStep = $discount !== null ? + new AnniversaryConditionStep(true, $discount->getTolerance()) : + new AnniversaryConditionStep(); + + $event->setData( + $event->getData()->withStep( + $conditionStep, + AnniversaryConditionStep::IDENTIFIER, + 'Anniversary Condition', + -45 // Priority + ) + ); + } +} diff --git a/code_samples/discounts/src/Discounts/Step/AnniversaryConditionStepEventSubscriber.php b/code_samples/discounts/src/Discounts/Step/Step2/AnniversaryConditionStepEventSubscriber.php similarity index 100% rename from code_samples/discounts/src/Discounts/Step/AnniversaryConditionStepEventSubscriber.php rename to code_samples/discounts/src/Discounts/Step/Step2/AnniversaryConditionStepEventSubscriber.php diff --git a/code_samples/discounts/src/Form/Data/PurchasingPowerParityValue.php b/code_samples/discounts/src/Form/Data/PurchasingPowerParityValue.php new file mode 100644 index 0000000000..3d2e6430d3 --- /dev/null +++ b/code_samples/discounts/src/Form/Data/PurchasingPowerParityValue.php @@ -0,0 +1,9 @@ +setDesc('Regional'), + ]; + } +} diff --git a/code_samples/discounts/src/Form/FormMapper/PurchasingPowerParityValueMapper.php b/code_samples/discounts/src/Form/FormMapper/PurchasingPowerParityValueMapper.php new file mode 100644 index 0000000000..50a51c63e7 --- /dev/null +++ b/code_samples/discounts/src/Form/FormMapper/PurchasingPowerParityValueMapper.php @@ -0,0 +1,62 @@ +getRule(); + if (!$discountRule instanceof PurchasingPowerParityRule) { + throw new LogicException('Not implemented'); + } + + return new PurchasingPowerParityValue(); + } + + public function mapCreateDataToStruct( + DiscountValueInterface $data, + DiscountCreateStruct $struct + ): void { + $this->addRuleToStruct($data, $struct); + } + + public function mapUpdateDataToStruct( + DiscountInterface $discount, + DiscountValueInterface $data, + DiscountUpdateStruct $struct + ): void { + $this->addRuleToStruct($data, $struct); + } + + /** + * @param \Ibexa\Contracts\Discounts\Value\Struct\DiscountCreateStruct|\Ibexa\Contracts\Discounts\Value\Struct\DiscountUpdateStruct $struct + */ + private function addRuleToStruct(DiscountValueInterface $data, $struct): void + { + if (!$data instanceof PurchasingPowerParityValue) { + throw new LogicException('Not implemented'); + } + + $rule = new PurchasingPowerParityRule(); + $struct->setRule($rule); + } +} diff --git a/code_samples/discounts/src/Form/Type/DiscountValue/PurchasingPowerParityValueType.php b/code_samples/discounts/src/Form/Type/DiscountValue/PurchasingPowerParityValueType.php new file mode 100644 index 0000000000..098e04149b --- /dev/null +++ b/code_samples/discounts/src/Form/Type/DiscountValue/PurchasingPowerParityValueType.php @@ -0,0 +1,75 @@ + + */ +final class PurchasingPowerParityValueType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $availableRegionHandler = static function (FormInterface $form, PurchasingPowerParityValue $data): void { + $regions = $data->getDiscountData()->getGeneralProperties()->getRegions(); + $regionNames = implode(', ', array_map(static function (RegionInterface $region): string { + return $region->getIdentifier(); + }, $regions)); + + $options = [ + 'required' => false, + 'disabled' => true, + 'label' => 'This discount applies to the following regions', + 'data' => $regionNames, + ]; + + $form->add('value', TextType::class, $options); + }; + + $builder->add('type', FormType::class, [ + 'mapped' => false, + 'label' => false, + ]); + + $builder->addEventListener( + FormEvents::PRE_SET_DATA, + static function (PreSetDataEvent $event) use ($availableRegionHandler): void { + $form = $event->getForm(); + $availableRegionHandler($form, $event->getData()); + }, + ); + $builder->get('type')->addEventListener( + FormEvents::POST_SUBMIT, + static function (PostSubmitEvent $event) use ($availableRegionHandler): void { + $form = $event->getForm()->getParent(); + assert($form !== null); + $availableRegionHandler($form, $form->getData()); + }, + ); + } + + public function getParent(): string + { + return DiscountValueType::class; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => PurchasingPowerParityValue::class, + ]); + } +} diff --git a/docs/discounts/extend_discounts.md b/docs/discounts/extend_discounts.md index d8226041ed..e948c5137d 100644 --- a/docs/discounts/extend_discounts.md +++ b/docs/discounts/extend_discounts.md @@ -18,7 +18,7 @@ Together with the existing [events](event_reference.md) and the [Discounts PHP A - Konrad Oboza: [Introduction to the Discounts system in Ibexa DXP](https://www.youtube.com/watch?v=kTgtxY38srw) - Paweł Niedzielski: [Extending new Discounts to suit your needs](https://www.youtube.com/watch?v=pDJxEKJLwPs) -## Create custom conditions +## Create custom conditions and rules With custom [conditions](discounts_api.md#conditions) you can create more advanced discounts that apply only in specific scenarios. @@ -122,7 +122,7 @@ You can now use the condition using the PHP API. To learn how to integrate it into the back office, see [Extend Discounts wizard](extend_discounts_wizard.md). -## Create custom rules +### Implement custom rules The following example implements a [purchasing power parity](https://en.wikipedia.org/wiki/Purchasing_power_parity) discount, adjusting product's price in the cart based on buyer's region. You could use it, for example, in regions sharing the same currency and apply the rule only to them by using the [`IsInRegions` condition](discounts_api.md#conditions). diff --git a/docs/discounts/extend_discounts_wizard.md b/docs/discounts/extend_discounts_wizard.md index 3f1bed50b7..2411dde058 100644 --- a/docs/discounts/extend_discounts_wizard.md +++ b/docs/discounts/extend_discounts_wizard.md @@ -49,8 +49,8 @@ It contains the step identifier, properties for storing form data, and extends t Then, create a new event listener listening to the [`CreateFormDataEvent` and `MapDiscountToFormDataEvent` events](discounts_events.md#form): -``` php hl_lines="21-22 31-55" -[[= include_file('code_samples/discounts/src/Discounts/Step/AnniversaryConditionStepEventSubscriber.php') =]] +``` php hl_lines="18-19 26-50" +[[= include_file('code_samples/discounts/src/Discounts/Step/Step1/AnniversaryConditionStepEventSubscriber.php') =]] ``` Attaching the `addAnniversaryConditionStep()` method to both these events adds the custom step both in discount creation and edit forms. @@ -88,10 +88,15 @@ The new form step, including its form fields, are now part of the discounts wiza The last task is making sure that the form data is correctly saved by attaching it to the discounts API structs. -The previously created `addStepDataToStruct()` method in `AnniversaryConditionStepEventSubscriber` is responsible for it: +Expand the previously created `AnniversaryConditionStepEventSubscriber` to listen to two additional events: + +- [`CreateDiscountCreateStructEvent`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-Event-CreateDiscountCreateStructEvent.html) +- [`CreateDiscountUpdateStructEvent`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-Event-CreateDiscountUpdateStructEvent.html) + +and add the `addStepDataToStruct()` method: ``` php hl_lines="23-24 57-70" -[[= include_file('code_samples/discounts/src/Discounts/Step/AnniversaryConditionStepEventSubscriber.php') =]] +[[= include_file('code_samples/discounts/src/Discounts/Step/Step2/AnniversaryConditionStepEventSubscriber.php') =]] ``` When the form is submitted, this method extracts information whether the store manager enabled the anniversary discount in the form and adds the condition to make sure this data is properly saved. @@ -99,3 +104,7 @@ When the form is submitted, this method extracts information whether the store m The custom condition is now integrated with the discounts wizard and can be used by store managers to attract new customers. ## Integrate custom rules + +This example continues the [purchasing power parity rule example](extend_discounts.md#implement-custom-rules), integrating the rule with the wizard. + + From b574aefa15475ecb380f4d955c9e43fb74734680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Wed, 26 Nov 2025 16:10:07 +0100 Subject: [PATCH 09/16] Extending wizard --- docs/discounts/extend_discounts_wizard.md | 47 +++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/discounts/extend_discounts_wizard.md b/docs/discounts/extend_discounts_wizard.md index 2411dde058..228f97f7e0 100644 --- a/docs/discounts/extend_discounts_wizard.md +++ b/docs/discounts/extend_discounts_wizard.md @@ -107,4 +107,51 @@ The custom condition is now integrated with the discounts wizard and can be used This example continues the [purchasing power parity rule example](extend_discounts.md#implement-custom-rules), integrating the rule with the wizard. +First, we need to create a new service implementing the `DiscountValueMapperInterface` interface, responsible for handling the new rule type: +``` php hl_lines="59-60" +[[= include_file('code_samples/discounts/src/Form/FormMapper/PurchasingPowerParityValueMapper.php') =]] +``` + +It uses an `PurchasingPowerParityValue` object to store the form data. +In the example, the data about discount value is stored directly in rule's code and the object does not require any additional properties. + + +``` php +[[= include_file('code_samples/discounts/src/Form/Data/PurchasingPowerParityValue.php', 0, 9) =]] +``` + +This value mapper is used by a new form mapper, dedicated to the new rule type: + +``` php +[[= include_file('code_samples/discounts/src/Form/FormMapper/PurchasingPowerParityFormMapper.php') =]] +``` + +Link them together when defining the services: + +``` yaml + App\Form\FormMapper\PurchasingPowerParityValueMapper: ~ + + App\Form\FormMapper\PurchasingPowerParityFormMapper: + arguments: + $discountValueMapper: '@App\Form\FormMapper\PurchasingPowerParityValueMapper' +``` + +The [`DiscountFormMapperInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountFormMapperInterface.html) acts as a registry, finding a form mapper dedicated for given rule type and delegating to the the responsibility of building the form. + +As each rule type might have a different rule calculation logic, each rule must have a different "Discount value" step in the form. + +To create it, create a dedicated class implementing the [`DiscountValueFormTypeMapperInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-Admin-Form-DiscountValueFormTypeMapperInterface.html) + +``` php +[[= include_file('code_samples/discounts/src/Form/FormMapper/PurchasingPowerParityDiscountValueFormTypeMapper.php') =]] +``` + +and add a dedicated value type class: + +``` php hl_lines="26-40 47-61 72" +[[= include_file('code_samples/discounts/src/Form/Type/DiscountValue/PurchasingPowerParityValueType.php') =]] +``` + +In the example above, the discount value step is used to to display a read-only field with regions the discount is limited to. +The `$availableRegionHandler` callback function extracts the selected regions and modifies the form as needed, using the `FormEvents::PRE_SET_DATA` and `FormEvents::POST_SUBMIT` events. From c2157dc71c8d86e5ec3e32ffc6736590e298a3fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Thu, 27 Nov 2025 00:14:52 +0100 Subject: [PATCH 10/16] FInal touches --- .../Step/AnniversaryConditionStep.php | 6 ++--- .../Form/Data/PurchasingPowerParityValue.php | 1 + docs/discounts/extend_discounts_wizard.md | 22 +++++++++++++------ 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/code_samples/discounts/src/Discounts/Step/AnniversaryConditionStep.php b/code_samples/discounts/src/Discounts/Step/AnniversaryConditionStep.php index bfe53f0b1f..852f913265 100644 --- a/code_samples/discounts/src/Discounts/Step/AnniversaryConditionStep.php +++ b/code_samples/discounts/src/Discounts/Step/AnniversaryConditionStep.php @@ -8,11 +8,11 @@ final class AnniversaryConditionStep extends AbstractDiscountStep { public const IDENTIFIER = 'anniversary_condition_step'; - public ?bool $enabled; + public bool $enabled; - public ?int $tolerance; + public int $tolerance; - public function __construct(?bool $enabled = false, ?int $tolerance = 0) + public function __construct(bool $enabled = false, int $tolerance = 0) { $this->enabled = $enabled; $this->tolerance = $tolerance; diff --git a/code_samples/discounts/src/Form/Data/PurchasingPowerParityValue.php b/code_samples/discounts/src/Form/Data/PurchasingPowerParityValue.php index 3d2e6430d3..bf7f19ec2d 100644 --- a/code_samples/discounts/src/Form/Data/PurchasingPowerParityValue.php +++ b/code_samples/discounts/src/Form/Data/PurchasingPowerParityValue.php @@ -6,4 +6,5 @@ final class PurchasingPowerParityValue extends AbstractDiscountValue { + public string $value; } diff --git a/docs/discounts/extend_discounts_wizard.md b/docs/discounts/extend_discounts_wizard.md index 228f97f7e0..d2a5b6d08d 100644 --- a/docs/discounts/extend_discounts_wizard.md +++ b/docs/discounts/extend_discounts_wizard.md @@ -12,7 +12,7 @@ month_change: true For the store managers to use your [custom conditions and rules](extend_discounts.md#create-custom-conditions), you need to integrate them into the back office discounts creation form. -This form is built using [Symfony Forms]([[= symfony_doc=]]/forms.html) and the [`DiscountFormMapperInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountFormMapperInterface.html) is the core of the implementation. +This form is built using [Symfony Forms]([[= symfony_doc=]]/forms.html) and the [`DiscountFormMapperInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountFormMapperInterface.html) interface is at the core of the implementation. It provides a two-way mapping between the form structures (used to render the form) and the PHP API values used to create the discounts by offering methods related to: @@ -38,7 +38,7 @@ See [discount's form events](discounts_events.md#form-events) for a list of the ## Integrate custom conditions This example continues the [anniversary discount condition example](extend_discounts.md#implement-custom-condition), integrating the condition with the wizard by adding a dedicated step with condition options. -The condition will be limited to cart discounts only. +The new step will be limited to cart discounts only. To add a custom step, create a value object representing the step. It contains the step identifier, properties for storing form data, and extends the [`AbstractDiscountStep`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-Admin-Form-Data-AbstractDiscountStep.html): @@ -107,18 +107,16 @@ The custom condition is now integrated with the discounts wizard and can be used This example continues the [purchasing power parity rule example](extend_discounts.md#implement-custom-rules), integrating the rule with the wizard. -First, we need to create a new service implementing the `DiscountValueMapperInterface` interface, responsible for handling the new rule type: +First, create a new service implementing the `DiscountValueMapperInterface` interface, responsible for handling the new rule type: ``` php hl_lines="59-60" [[= include_file('code_samples/discounts/src/Form/FormMapper/PurchasingPowerParityValueMapper.php') =]] ``` -It uses an `PurchasingPowerParityValue` object to store the form data. -In the example, the data about discount value is stored directly in rule's code and the object does not require any additional properties. - +It uses an `PurchasingPowerParityValue` object to store the form data: ``` php -[[= include_file('code_samples/discounts/src/Form/Data/PurchasingPowerParityValue.php', 0, 9) =]] +[[= include_file('code_samples/discounts/src/Form/Data/PurchasingPowerParityValue.php') =]] ``` This value mapper is used by a new form mapper, dedicated to the new rule type: @@ -155,3 +153,13 @@ and add a dedicated value type class: In the example above, the discount value step is used to to display a read-only field with regions the discount is limited to. The `$availableRegionHandler` callback function extracts the selected regions and modifies the form as needed, using the `FormEvents::PRE_SET_DATA` and `FormEvents::POST_SUBMIT` events. + +The last step consists of providing all the required translations. +Specify them in `translations/ibexa_discount.en.yaml`: + +``` yaml +ibexa.discount.type.purchasing_power_parity: Purchasing Power Parity +discount.rule_type.purchasing_power_parity: Purchasing Power Parity +``` + +The custom rule is now integrated with the discounts wizard and can be used by store managers to offer new discounts. From 65f1ce5b63bacff1cfe25c611e6d07f071bf0d5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Thu, 27 Nov 2025 00:29:19 +0100 Subject: [PATCH 11/16] Fixes before review --- code_samples/discounts/config/services.yaml | 29 ------------------- .../order_management/order_management_api.md | 2 +- docs/discounts/extend_discounts.md | 1 - docs/discounts/extend_discounts_wizard.md | 16 +++++----- 4 files changed, 9 insertions(+), 39 deletions(-) delete mode 100644 code_samples/discounts/config/services.yaml diff --git a/code_samples/discounts/config/services.yaml b/code_samples/discounts/config/services.yaml deleted file mode 100644 index 0706a557c9..0000000000 --- a/code_samples/discounts/config/services.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# This file is the entry point to configure your own services. -# Files in the packages/ subdirectory configure your dependencies. - -# Put parameters here that don't need to change on each machine where the app is deployed -# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration -parameters: - -services: - # default configuration for services in *this* file - _defaults: - autowire: true # Automatically injects dependencies in your services. - autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. - - # makes classes in src/ available to be used as services - # this creates a service per class whose id is the fully-qualified class name - App\: - resource: '../src/' - exclude: - - '../src/DependencyInjection/' - - '../src/Entity/' - - '../src/Kernel.php' - - # add more service definitions when explicit configuration is needed - # please note that last definitions always *replace* previous ones - - App\Discounts\CustomDiscountValueFormatter: - tags: - - name: ibexa.discounts.value.formatter - rule_type: 'referral_rule' diff --git a/docs/commerce/order_management/order_management_api.md b/docs/commerce/order_management/order_management_api.md index b398c3e6f2..bca070cbdb 100644 --- a/docs/commerce/order_management/order_management_api.md +++ b/docs/commerce/order_management/order_management_api.md @@ -24,7 +24,7 @@ To access a single order by using its string identifier, use the [`OrderServiceI Use the returned [`OrderInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-OrderManagement-Value-Order-OrderInterface.html) value object to access details about the order. -See the [Discounts API](discounts_api.md#resolving-prices) to learn how to retrieve applied discount details from the order's context. +See the [Discounts API](discounts_api.md#retrieve-applied-discounts) to learn how to retrieve applied discount details from the order's context. ### Get single order by ID diff --git a/docs/discounts/extend_discounts.md b/docs/discounts/extend_discounts.md index e948c5137d..2abeb51c11 100644 --- a/docs/discounts/extend_discounts.md +++ b/docs/discounts/extend_discounts.md @@ -190,4 +190,3 @@ It uses one of the existing [discount search criterions](discounts_criteria.md). arguments: $inner: '@.inner' ``` - diff --git a/docs/discounts/extend_discounts_wizard.md b/docs/discounts/extend_discounts_wizard.md index d2a5b6d08d..4df92952ff 100644 --- a/docs/discounts/extend_discounts_wizard.md +++ b/docs/discounts/extend_discounts_wizard.md @@ -10,7 +10,7 @@ month_change: true ## Introduction -For the store managers to use your [custom conditions and rules](extend_discounts.md#create-custom-conditions), you need to integrate them into the back office discounts creation form. +For the store managers to use your [custom conditions and rules](extend_discounts.md#implement-custom-condition), you need to integrate them into the back office discounts creation form. This form is built using [Symfony Forms]([[= symfony_doc=]]/forms.html) and the [`DiscountFormMapperInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountFormMapperInterface.html) interface is at the core of the implementation. @@ -25,20 +25,20 @@ They include: - [`createFormData()`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountFormMapperInterface.html#method_createFormData) renders the form before the discount is created - [`mapDiscountToFormData()`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountFormMapperInterface.html#method_mapDiscountToFormData) renders the form when the discount already exists. It fills the discount edit form with the saved discount details -The data mapping methods are responsible for transforming the form data into structures compatible with the [Discount's PHP API](discounts_api.md) services like [`DiscountServiceInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountServiceInterface.html) and [`DiscountCodeServiceInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-DiscountsCodes-DiscountCodeServiceInterface.html). +The data mapping methods are responsible for transforming the form data into structures compatible with the [Discount's PHP API](discounts_api.md) services like [`DiscountServiceInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountServiceInterface.html) and [`DiscountCodeServiceInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-DiscountsCodes-DiscountCodeServiceInterface.html). They include: - [`mapCreateDataToStruct()`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountFormMapperInterface.html#method_mapCreateDataToStruct) creates the [`DiscountCreateStruct`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-Value-Struct-DiscountCreateStruct.html) object to create the discount - [`mapUpdateDataToStruct()`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountFormMapperInterface.html#method_mapUpdateDataToStruct) creates the [`DiscountUpdateStruct`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-Value-Struct-DiscountUpdateStruct.html) object to update the discount - [`mapEditTranslateDataToStruct()`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountFormMapperInterface.html#method_mapEditTranslateDataToStruct) creates the [`TranslationStruct`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-Value-Struct-DiscountTranslationStruct.html) objects for [translating the discounts](discounts_api.md#discount-translations) -In addition to these methods, the main form mapper and the form mappers responsible for each step in the wizard dispatch events that you can use to add your custom logic. +In addition to these methods, the main form mapper and the form mappers responsible for each step in the wizard dispatch events that you can use to add your custom logic. See [discount's form events](discounts_events.md#form-events) for a list of the available events. ## Integrate custom conditions This example continues the [anniversary discount condition example](extend_discounts.md#implement-custom-condition), integrating the condition with the wizard by adding a dedicated step with condition options. -The new step will be limited to cart discounts only. +The example limits the new step to cart discounts only. To add a custom step, create a value object representing the step. It contains the step identifier, properties for storing form data, and extends the [`AbstractDiscountStep`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-Admin-Form-Data-AbstractDiscountStep.html): @@ -60,12 +60,12 @@ The method first verifies if the form renders the cart discount wizard, accordin Then, it creates the `AnniversaryConditionStep` object. If the discount existed already and is being edited, the saved values are used to populate the form. -Finally, the new step is added to the wizard using the `withStep()` method, using `45` as step priority. +Finally, the new step is added to the wizard using the `withStep()` method, using `45` as step priority. Each of the existing form steps has its own priority, allowing you to add your custom steps between them. | Step name | Priority | |---| ---| -| General properties | 50| +| General properties | 50| | Target group | -20 | | Products | -30 | | Conditions | -40 | @@ -135,7 +135,7 @@ Link them together when defining the services: $discountValueMapper: '@App\Form\FormMapper\PurchasingPowerParityValueMapper' ``` -The [`DiscountFormMapperInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountFormMapperInterface.html) acts as a registry, finding a form mapper dedicated for given rule type and delegating to the the responsibility of building the form. +The [`DiscountFormMapperInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountFormMapperInterface.html) acts as a registry, finding a form mapper dedicated for given rule type and delegating to the responsibility of building the form. As each rule type might have a different rule calculation logic, each rule must have a different "Discount value" step in the form. @@ -151,7 +151,7 @@ and add a dedicated value type class: [[= include_file('code_samples/discounts/src/Form/Type/DiscountValue/PurchasingPowerParityValueType.php') =]] ``` -In the example above, the discount value step is used to to display a read-only field with regions the discount is limited to. +In the example above, the discount value step is used to display a read-only field with regions the discount is limited to. The `$availableRegionHandler` callback function extracts the selected regions and modifies the form as needed, using the `FormEvents::PRE_SET_DATA` and `FormEvents::POST_SUBMIT` events. The last step consists of providing all the required translations. From 1d445f23e0d14e94a68bc1258c7b8d60f8020aca Mon Sep 17 00:00:00 2001 From: mnocon Date: Wed, 26 Nov 2025 23:43:33 +0000 Subject: [PATCH 12/16] PHP & JS CS Fixes --- .../Form/Type/DiscountValue/PurchasingPowerParityValueType.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code_samples/discounts/src/Form/Type/DiscountValue/PurchasingPowerParityValueType.php b/code_samples/discounts/src/Form/Type/DiscountValue/PurchasingPowerParityValueType.php index 098e04149b..cb998d90b2 100644 --- a/code_samples/discounts/src/Form/Type/DiscountValue/PurchasingPowerParityValueType.php +++ b/code_samples/discounts/src/Form/Type/DiscountValue/PurchasingPowerParityValueType.php @@ -19,7 +19,7 @@ /** * @extends \Symfony\Component\Form\AbstractType<\App\Form\Data\PurchasingPowerParityValue> */ -final class PurchasingPowerParityValueType extends AbstractType +final class PurchasingPowerParityValueType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { From 4ccd0976e48d6d311e1a03447991b055ecb95a56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Thu, 27 Nov 2025 09:14:00 +0100 Subject: [PATCH 13/16] Rebuild From a1a2b34d1e008ab231e239580cdb5eaee168a6b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Mon, 1 Dec 2025 10:06:52 +0100 Subject: [PATCH 14/16] Review fixes --- .../CurrentUserRegistrationDateResolver.php | 3 ++- .../Discounts/ExpressionProvider/IsAnniversaryResolver.php | 7 +++---- docs/discounts/extend_discounts.md | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/code_samples/discounts/src/Discounts/ExpressionProvider/CurrentUserRegistrationDateResolver.php b/code_samples/discounts/src/Discounts/ExpressionProvider/CurrentUserRegistrationDateResolver.php index e3b8f92b2f..4f6d4f9fde 100644 --- a/code_samples/discounts/src/Discounts/ExpressionProvider/CurrentUserRegistrationDateResolver.php +++ b/code_samples/discounts/src/Discounts/ExpressionProvider/CurrentUserRegistrationDateResolver.php @@ -19,7 +19,8 @@ public function __construct(PermissionResolver $permissionResolver, UserService $this->userService = $userService; } - /** @return array{current_user_registration_date: \DateTimeInterface} + /** + * @return array{current_user_registration_date: \DateTimeInterface} */ public function getVariables(PriceContextInterface $priceContext): array { diff --git a/code_samples/discounts/src/Discounts/ExpressionProvider/IsAnniversaryResolver.php b/code_samples/discounts/src/Discounts/ExpressionProvider/IsAnniversaryResolver.php index a430f6dd51..cae6246c3a 100644 --- a/code_samples/discounts/src/Discounts/ExpressionProvider/IsAnniversaryResolver.php +++ b/code_samples/discounts/src/Discounts/ExpressionProvider/IsAnniversaryResolver.php @@ -4,7 +4,6 @@ use DateTimeImmutable; use DateTimeInterface; -use EzSystems\EzPlatformGraphQL\GraphQL\Mutation\InputHandler\FieldType\Date; final class IsAnniversaryResolver { @@ -28,15 +27,15 @@ public function __invoke(DateTimeInterface $date, int $tolerance = 0): bool private function unifyYear(DateTimeInterface $date): DateTimeImmutable { // Create a new date using the reference year but with the same month and day - $date = DateTimeImmutable::createFromFormat( + $newDate = DateTimeImmutable::createFromFormat( self::YEAR_MONTH_DAY_FORMAT, self::REFERENCE_YEAR . '-' . $date->format(self::MONTH_DAY_FORMAT) ); - if ($date === false) { + if ($newDate === false) { throw new \RuntimeException('Failed to unify year for date.'); } - return $date; + return $newDate; } } diff --git a/docs/discounts/extend_discounts.md b/docs/discounts/extend_discounts.md index 2abeb51c11..05eb51aacf 100644 --- a/docs/discounts/extend_discounts.md +++ b/docs/discounts/extend_discounts.md @@ -175,7 +175,7 @@ To do it, create a class implementing the [`DiscountValueFormatterInterface`](/a ## Change discount priority -You can change the [the defualt discount priority](discounts_guide.md#discounts-priority) by creating a class implementing the [`DiscountPrioritizationStrategyInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountPrioritizationStrategyInterface.html) and aliasing to it the default implementation. +You can change the [the default discount priority](discounts_guide.md#discounts-priority) by creating a class implementing the [`DiscountPrioritizationStrategyInterface`](/api/php_api/php_api_reference/classes/Ibexa-Contracts-Discounts-DiscountPrioritizationStrategyInterface.html) and aliasing to it the default implementation. The example below decorates the default implementation to prioritize recently updated discounts above all the others. It uses one of the existing [discount search criterions](discounts_criteria.md). From 2b7c70cbe2b43db1f6c94c885d3ce38b76b67a85 Mon Sep 17 00:00:00 2001 From: mnocon Date: Mon, 1 Dec 2025 09:22:52 +0000 Subject: [PATCH 15/16] PHP & JS CS Fixes --- .../ExpressionProvider/CurrentUserRegistrationDateResolver.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code_samples/discounts/src/Discounts/ExpressionProvider/CurrentUserRegistrationDateResolver.php b/code_samples/discounts/src/Discounts/ExpressionProvider/CurrentUserRegistrationDateResolver.php index 4f6d4f9fde..4e8e6b9867 100644 --- a/code_samples/discounts/src/Discounts/ExpressionProvider/CurrentUserRegistrationDateResolver.php +++ b/code_samples/discounts/src/Discounts/ExpressionProvider/CurrentUserRegistrationDateResolver.php @@ -19,7 +19,7 @@ public function __construct(PermissionResolver $permissionResolver, UserService $this->userService = $userService; } - /** + /** * @return array{current_user_registration_date: \DateTimeInterface} */ public function getVariables(PriceContextInterface $priceContext): array From 1f73465916b336e35bb38fcfdf3e468b7f39f591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Noco=C5=84?= Date: Mon, 1 Dec 2025 11:09:17 +0100 Subject: [PATCH 16/16] Removed not needed code sample ranges --- docs/discounts/extend_discounts.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/discounts/extend_discounts.md b/docs/discounts/extend_discounts.md index 05eb51aacf..c49d2a0929 100644 --- a/docs/discounts/extend_discounts.md +++ b/docs/discounts/extend_discounts.md @@ -131,13 +131,13 @@ To implement a custom rule, create a class implementing the [`DiscountRuleInterf ``` php -[[= include_file('code_samples/discounts/src/Discounts/Rule/PurchasingPowerParityRule.php', 0, 42) =]] +[[= include_file('code_samples/discounts/src/Discounts/Rule/PurchasingPowerParityRule.php') =]] ``` As with conditions, create a dedicated rule factory: ``` php -[[= include_file('code_samples/discounts/src/Discounts/Rule/PurchasingPowerParityRuleFactory.php', 0, 14) =]] +[[= include_file('code_samples/discounts/src/Discounts/Rule/PurchasingPowerParityRuleFactory.php') =]] ``` Then, mark it as a service using the `ibexa.discounts.rule.factory` service tag and specify the rule's type.