-
Notifications
You must be signed in to change notification settings - Fork 82
IBX-9680: Extending discounts #2936
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 4.6
Are you sure you want to change the base?
Changes from all commits
3b566b3
740ad1e
752ef12
85f4fc9
948c730
80a725a
98f7569
8b2e417
b574aef
c2157dc
65f1ce5
1d445f2
4ccd097
a1a2b34
2b7c70c
1f73465
6742514
81ddc67
e105acb
1e18e9c
85aa5da
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| <?php declare(strict_types=1); | ||
|
|
||
| namespace App\Command; | ||
|
|
||
| use Exception; | ||
| use Ibexa\Contracts\Core\Repository\PermissionResolver; | ||
| use Ibexa\Contracts\Core\Repository\UserService; | ||
| use Ibexa\Contracts\OrderManagement\OrderServiceInterface; | ||
| use Ibexa\Contracts\ProductCatalog\CurrencyServiceInterface; | ||
| use Ibexa\Contracts\ProductCatalog\PriceResolverInterface; | ||
| use Ibexa\Contracts\ProductCatalog\ProductPriceServiceInterface; | ||
| use Ibexa\Contracts\ProductCatalog\ProductServiceInterface; | ||
| use Ibexa\Contracts\ProductCatalog\Values\Price\PriceContext; | ||
| use Ibexa\Contracts\ProductCatalog\Values\Price\PriceEnvelopeInterface; | ||
| use Ibexa\Discounts\Value\Price\Stamp\DiscountStamp; | ||
| use Ibexa\OrderManagement\Discounts\Value\DiscountsData; | ||
| use Money\Money; | ||
| use Symfony\Component\Console\Command\Command; | ||
| use Symfony\Component\Console\Input\InputInterface; | ||
| use Symfony\Component\Console\Output\OutputInterface; | ||
|
|
||
| final class OrderPriceCommand extends Command | ||
| { | ||
| protected static $defaultName = 'app:discounts:prices'; | ||
|
|
||
| private PermissionResolver $permissionResolver; | ||
|
|
||
| private UserService $userService; | ||
|
|
||
| private ProductServiceInterface $productService; | ||
|
|
||
| private OrderServiceInterface $orderService; | ||
|
|
||
| private ProductPriceServiceInterface $productPriceService; | ||
|
|
||
| private CurrencyServiceInterface $currencyService; | ||
|
|
||
| private PriceResolverInterface $priceResolver; | ||
|
|
||
| public function __construct( | ||
| PermissionResolver $permissionResolver, | ||
| UserService $userService, | ||
| ProductServiceInterface $productService, | ||
| OrderServiceInterface $orderService, | ||
| ProductPriceServiceInterface $productPriceService, | ||
| CurrencyServiceInterface $currencyService, | ||
| PriceResolverInterface $priceResolver | ||
| ) { | ||
| parent::__construct(); | ||
|
|
||
| $this->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')); | ||
|
|
||
| $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); | ||
|
|
||
| $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($basePrice->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(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| <?php declare(strict_types=1); | ||
|
|
||
| namespace App\Discounts\Condition; | ||
|
|
||
| use Ibexa\Contracts\Discounts\Value\DiscountConditionInterface; | ||
| use Ibexa\Discounts\Value\AbstractDiscountExpressionAware; | ||
|
|
||
| final class IsAccountAnniversary extends AbstractDiscountExpressionAware implements DiscountConditionInterface | ||
| { | ||
| public const IDENTIFIER = 'is_account_anniversary'; | ||
|
|
||
| public function __construct(?int $tolerance = null) | ||
| { | ||
| parent::__construct([ | ||
| 'tolerance' => $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)'; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| <?php declare(strict_types=1); | ||
|
|
||
| namespace App\Discounts\Condition; | ||
|
|
||
| use Ibexa\Contracts\Discounts\Value\DiscountConditionInterface; | ||
| use Ibexa\Discounts\Repository\DiscountCondition\DiscountConditionFactoryInterface; | ||
|
|
||
| final class IsAccountAnniversaryConditionFactory implements DiscountConditionFactoryInterface | ||
| { | ||
| public function createDiscountCondition(?array $expressionValues): DiscountConditionInterface | ||
| { | ||
| return new IsAccountAnniversary( | ||
| $expressionValues['tolerance'] ?? null | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| <?php declare(strict_types=1); | ||
|
|
||
| namespace App\Discounts\ExpressionProvider; | ||
|
|
||
| use Ibexa\Contracts\Core\Repository\PermissionResolver; | ||
| use Ibexa\Contracts\Core\Repository\UserService; | ||
| use Ibexa\Contracts\Discounts\DiscountVariablesResolverInterface; | ||
| use Ibexa\Contracts\ProductCatalog\Values\Price\PriceContextInterface; | ||
|
|
||
| final class CurrentUserRegistrationDateResolver implements DiscountVariablesResolverInterface | ||
| { | ||
| private PermissionResolver $permissionResolver; | ||
|
|
||
| private UserService $userService; | ||
|
|
||
| public function __construct(PermissionResolver $permissionResolver, UserService $userService) | ||
| { | ||
| $this->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, | ||
| ]; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| <?php declare(strict_types=1); | ||
|
|
||
| namespace App\Discounts\ExpressionProvider; | ||
|
|
||
| use DateTimeImmutable; | ||
| use DateTimeInterface; | ||
|
|
||
| final class IsAnniversaryResolver | ||
| { | ||
| private const YEAR_MONTH_DAY_FORMAT = 'Y-m-d'; | ||
|
|
||
| private const MONTH_DAY_FORMAT = 'm-d'; | ||
|
|
||
| private const REFERENCE_YEAR = 2000; | ||
|
|
||
| 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 | ||
| return $diff <= $tolerance; | ||
| } | ||
|
|
||
| private function unifyYear(DateTimeInterface $date): DateTimeImmutable | ||
| { | ||
| // Create a new date using the reference year but with the same month and day | ||
| $newDate = DateTimeImmutable::createFromFormat( | ||
| self::YEAR_MONTH_DAY_FORMAT, | ||
| self::REFERENCE_YEAR . '-' . $date->format(self::MONTH_DAY_FORMAT) | ||
| ); | ||
|
Comment on lines
+30
to
+33
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cool concept, but it won't work for leap years - this code breaks on 29th of february :)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't see how, could you please elaborate? The reference year is set to php > $date = \DateTimeImmutable::createFromFormat('Y-m-d', '2000-02-29');
php > var_dump($date);
object(DateTimeImmutable)#1 (3) {
["date"]=>
string(26) "2000-02-29 17:22:05.000000"
["timezone_type"]=>
int(3)
["timezone"]=>
string(3) "UTC"
}I don't think it should break on leap years.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've used the following code to check. <?php
final class IsAnniversaryResolver
{
private const YEAR_MONTH_DAY_FORMAT = 'Y-m-d';
private const MONTH_DAY_FORMAT = 'm-d';
private const REFERENCE_YEAR = 2000;
public function __invoke(DateTimeInterface $date, int $tolerance = 0): bool
{
$d1 = $this->unifyYear(new DateTimeImmutable('2025-03-01 12:00:00'));
// Below does not work either
// $d1 = $this->unifyYear(new DateTimeImmutable('2025-02-29 12:00:00'));
$d2 = $this->unifyYear($date);
$diff = $d1->diff($d2, true);
// Check if the difference between dates is within the tolerance
return $diff->days <= $tolerance;
}
private function unifyYear(DateTimeInterface $date): DateTimeImmutable
{
// Create a new date using the reference year but with the same month and day
$newDate = DateTimeImmutable::createFromFormat(
self::YEAR_MONTH_DAY_FORMAT,
self::REFERENCE_YEAR . '-' . $date->format(self::MONTH_DAY_FORMAT)
);
if ($newDate === false) {
throw new RuntimeException('Failed to unify year for date.');
}
return $newDate;
}
}
$resolver = new IsAnniversaryResolver();
var_dump($resolver->__invoke(new DateTime('2024-02-29 00:00:00')));
// bool(false)
var_dump($resolver->__invoke(new DateTime('2026-02-29 00:00:00')));
// bool(true)Basically,
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Right, TBH I'm not sure I'd treat 29-02 accounts in "real" implementation, would need a good spec from PM and lots of tests 😄 Probably would extend it to "anniversary week" to make the problem simpler. I've added a note mentioning the naive approach of this implementation in e105acb , to make the reader aware of the shortcuts taken. |
||
|
|
||
| if ($newDate === false) { | ||
| throw new \RuntimeException('Failed to unify year for date.'); | ||
| } | ||
|
|
||
| return $newDate; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| <?php declare(strict_types=1); | ||
|
|
||
| namespace App\Discounts; | ||
|
|
||
| use Ibexa\Contracts\Discounts\DiscountPrioritizationStrategyInterface; | ||
| use Ibexa\Contracts\Discounts\Value\Query\SortClause\UpdatedAt; | ||
|
|
||
| final class RecentDiscountPrioritizationStrategy implements DiscountPrioritizationStrategyInterface | ||
| { | ||
| private DiscountPrioritizationStrategyInterface $inner; | ||
|
|
||
| public function __construct(DiscountPrioritizationStrategyInterface $inner) | ||
| { | ||
| $this->inner = $inner; | ||
| } | ||
|
|
||
| public function getOrder(): array | ||
| { | ||
| return array_merge( | ||
| [new UpdatedAt()], | ||
| $this->inner->getOrder() | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Discounts\Rule; | ||
|
|
||
| use Ibexa\Contracts\Discounts\DiscountValueFormatterInterface; | ||
| use Ibexa\Contracts\Discounts\Value\DiscountRuleInterface; | ||
| use Money\Money; | ||
|
|
||
| final class PurchaseParityValueFormatter implements DiscountValueFormatterInterface | ||
| { | ||
| public function format(DiscountRuleInterface $discountRule, ?Money $money = null): string | ||
| { | ||
| return 'Regional discount'; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| <?php declare(strict_types=1); | ||
|
|
||
| namespace App\Discounts\Rule; | ||
|
|
||
| use Ibexa\Contracts\Discounts\Value\DiscountRuleInterface; | ||
| use Ibexa\Discounts\Value\AbstractDiscountExpressionAware; | ||
|
|
||
| final class PurchasingPowerParityRule extends AbstractDiscountExpressionAware implements DiscountRuleInterface | ||
| { | ||
| public const TYPE = 'purchasing_power_parity'; | ||
|
|
||
| private const DEFAULT_PARITY_MAP = [ | ||
| 'default' => 100, | ||
| 'germany' => 81.6, | ||
| 'france' => 80, | ||
| 'spain' => 69, | ||
| ]; | ||
|
|
||
| /** @param ?array<string, float> $powerParityMap */ | ||
| public function __construct(?array $powerParityMap = null) | ||
| { | ||
| parent::__construct( | ||
| [ | ||
| 'power_parity_map' => $powerParityMap ?? self::DEFAULT_PARITY_MAP, | ||
| ] | ||
| ); | ||
| } | ||
|
|
||
| /** @return array<string, float> */ | ||
| 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; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| <?php declare(strict_types=1); | ||
|
|
||
| namespace App\Discounts\Rule; | ||
|
|
||
| use Ibexa\Contracts\Discounts\Value\DiscountRuleInterface; | ||
| use Ibexa\Discounts\Repository\DiscountRule\DiscountRuleFactoryInterface; | ||
|
|
||
| final class PurchasingPowerParityRuleFactory implements DiscountRuleFactoryInterface | ||
| { | ||
| public function createDiscountRule(?array $expressionValues): DiscountRuleInterface | ||
| { | ||
| return new PurchasingPowerParityRule($expressionValues['power_parity_map'] ?? null); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| <?php declare(strict_types=1); | ||
|
|
||
| namespace App\Discounts\Step; | ||
|
|
||
| use Ibexa\Contracts\Discounts\Admin\Form\Data\AbstractDiscountStep; | ||
|
|
||
| final class AnniversaryConditionStep extends AbstractDiscountStep | ||
| { | ||
| public const IDENTIFIER = 'anniversary_condition_step'; | ||
|
|
||
| public bool $enabled; | ||
|
|
||
| public int $tolerance; | ||
|
|
||
| public function __construct(bool $enabled = false, int $tolerance = 0) | ||
| { | ||
| $this->enabled = $enabled; | ||
| $this->tolerance = $tolerance; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In general, whenever additional data might be needed for Discount condition/rule, it is heavily suggested to use data loaded on demand, by a function call.
Providing variable to expression engine means that it will be loaded always, regardless if the variable is actually needed for resolution or not.
I would rather suggest making this a function available within the engine. It could even use caching to ensure it's not loaded more than once.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Super valuable feedback, thank you! I've focused on the "how" and missed the "why".
I've added a note about variables vs functions: 6742514 (without rewriting the example, I hope that's ok)