Skip to content

Commit e63fbda

Browse files
mnoconSteveb-padriendupuis
authored
IBX-9680: Extending discounts (#2936)
* [WIP] Current status * PHP & JS CS Fixes * Started working on Forms * Current status * Current status * PHPStan is passing * Conditions ready * Conditions refactored * Extending wizard * FInal touches * Fixes before review * PHP & JS CS Fixes * Rebuild * Review fixes * PHP & JS CS Fixes * Removed not needed code sample ranges * Added a note about variable vs function * Update docs/discounts/extend_discounts.md Co-authored-by: Paweł Niedzielski <[email protected]> * Added note about simplified implementation * Apply suggestions from code review Co-authored-by: Adrien Dupuis <[email protected]> * Refactored the tables * Apply suggestions from code review Co-authored-by: Adrien Dupuis <[email protected]> * Review fixes * Apply suggestions from code review Co-authored-by: Adrien Dupuis <[email protected]> * Added better formatting prices * Removed not needed output --------- Co-authored-by: Paweł Niedzielski <[email protected]> Co-authored-by: Adrien Dupuis <[email protected]>
1 parent c8c5447 commit e63fbda

31 files changed

+1327
-36
lines changed
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace App\Command;
4+
5+
use Exception;
6+
use Ibexa\Contracts\Core\Repository\PermissionResolver;
7+
use Ibexa\Contracts\Core\Repository\UserService;
8+
use Ibexa\Contracts\OrderManagement\OrderServiceInterface;
9+
use Ibexa\Contracts\ProductCatalog\CurrencyServiceInterface;
10+
use Ibexa\Contracts\ProductCatalog\PriceResolverInterface;
11+
use Ibexa\Contracts\ProductCatalog\ProductPriceServiceInterface;
12+
use Ibexa\Contracts\ProductCatalog\ProductServiceInterface;
13+
use Ibexa\Contracts\ProductCatalog\Values\Price\PriceContext;
14+
use Ibexa\Contracts\ProductCatalog\Values\Price\PriceEnvelopeInterface;
15+
use Ibexa\Discounts\Value\Price\Stamp\DiscountStamp;
16+
use Ibexa\OrderManagement\Discounts\Value\DiscountsData;
17+
use Ibexa\ProductCatalog\Money\IntlMoneyFactory;
18+
use Money\Money;
19+
use Symfony\Component\Console\Command\Command;
20+
use Symfony\Component\Console\Input\InputInterface;
21+
use Symfony\Component\Console\Output\OutputInterface;
22+
23+
final class OrderPriceCommand extends Command
24+
{
25+
protected static $defaultName = 'app:discounts:prices';
26+
27+
private PermissionResolver $permissionResolver;
28+
29+
private UserService $userService;
30+
31+
private ProductServiceInterface $productService;
32+
33+
private OrderServiceInterface $orderService;
34+
35+
private ProductPriceServiceInterface $productPriceService;
36+
37+
private CurrencyServiceInterface $currencyService;
38+
39+
private PriceResolverInterface $priceResolver;
40+
41+
private IntlMoneyFactory $moneyFactory;
42+
43+
public function __construct(
44+
PermissionResolver $permissionResolver,
45+
UserService $userService,
46+
ProductServiceInterface $productService,
47+
OrderServiceInterface $orderService,
48+
ProductPriceServiceInterface $productPriceService,
49+
CurrencyServiceInterface $currencyService,
50+
PriceResolverInterface $priceResolver,
51+
IntlMoneyFactory $moneyFactory
52+
) {
53+
parent::__construct();
54+
55+
$this->permissionResolver = $permissionResolver;
56+
$this->userService = $userService;
57+
$this->productService = $productService;
58+
$this->orderService = $orderService;
59+
$this->productPriceService = $productPriceService;
60+
$this->currencyService = $currencyService;
61+
$this->priceResolver = $priceResolver;
62+
$this->moneyFactory = $moneyFactory;
63+
}
64+
65+
public function execute(InputInterface $input, OutputInterface $output): int
66+
{
67+
$this->permissionResolver->setCurrentUserReference($this->userService->loadUserByLogin('admin'));
68+
69+
$productCode = 'product_code_control_unit_0';
70+
$orderIdentifier = '4315bc58-1e96-4f21-82a0-15f736cbc4bc';
71+
$currencyCode = 'EUR';
72+
73+
$output->writeln('Product data:');
74+
$product = $this->productService->getProduct($productCode);
75+
$currency = $this->currencyService->getCurrencyByCode($currencyCode);
76+
77+
$basePrice = $this->productPriceService->getPriceByProductAndCurrency($product, $currency);
78+
$resolvedPrice = $this->priceResolver->resolvePrice($product, new PriceContext($currency));
79+
80+
if ($resolvedPrice === null) {
81+
throw new Exception('Could not resolve price for the product');
82+
}
83+
84+
$output->writeln(sprintf('Base price: %s', $this->formatPrice($basePrice->getMoney())));
85+
$output->writeln(sprintf('Discounted price: %s', $this->formatPrice($resolvedPrice->getMoney())));
86+
87+
if ($resolvedPrice instanceof PriceEnvelopeInterface) {
88+
/** @var \Ibexa\Discounts\Value\Price\Stamp\DiscountStamp $discountStamp */
89+
foreach ($resolvedPrice->all(DiscountStamp::class) as $discountStamp) {
90+
$output->writeln(
91+
sprintf(
92+
'Discount applied: %s , new amount: %s',
93+
$discountStamp->getDiscount()->getName(),
94+
$this->formatPrice(
95+
$discountStamp->getNewPrice()
96+
)
97+
)
98+
);
99+
}
100+
}
101+
102+
$output->writeln('Order details:');
103+
104+
$order = $this->orderService->getOrderByIdentifier($orderIdentifier);
105+
foreach ($order->getItems() as $item) {
106+
/** @var ?DiscountsData $discountData */
107+
$discountData = $item->getContext()['discount_data'] ?? null;
108+
if ($discountData instanceof DiscountsData) {
109+
$output->writeln(
110+
sprintf(
111+
'Product bought with discount: %s, base price: %s, discounted price: %s',
112+
$item->getProduct()->getName(),
113+
$this->formatPrice($discountData->getOriginalPrice()),
114+
$this->formatPrice(
115+
$item->getValue()->getUnitPriceGross()
116+
)
117+
)
118+
);
119+
} else {
120+
$output->writeln(
121+
sprintf(
122+
'Product bought with original price: %s, price: %s',
123+
$item->getProduct()->getName(),
124+
$this->formatPrice(
125+
$item->getValue()->getUnitPriceGross()
126+
)
127+
)
128+
);
129+
}
130+
}
131+
132+
return Command::SUCCESS;
133+
}
134+
135+
private function formatPrice(Money $money): string
136+
{
137+
return $this->moneyFactory->getMoneyFormatter()->format($money);
138+
}
139+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace App\Discounts\Condition;
4+
5+
use Ibexa\Contracts\Discounts\Value\DiscountConditionInterface;
6+
use Ibexa\Discounts\Value\AbstractDiscountExpressionAware;
7+
8+
final class IsAccountAnniversary extends AbstractDiscountExpressionAware implements DiscountConditionInterface
9+
{
10+
public const IDENTIFIER = 'is_account_anniversary';
11+
12+
public function __construct(?int $tolerance = null)
13+
{
14+
parent::__construct([
15+
'tolerance' => $tolerance ?? 0,
16+
]);
17+
}
18+
19+
public function getTolerance(): int
20+
{
21+
return $this->getExpressionValue('tolerance');
22+
}
23+
24+
public function getIdentifier(): string
25+
{
26+
return self::IDENTIFIER;
27+
}
28+
29+
public function getExpression(): string
30+
{
31+
return 'is_anniversary(current_user_registration_date, tolerance)';
32+
}
33+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace App\Discounts\Condition;
4+
5+
use Ibexa\Contracts\Discounts\Value\DiscountConditionInterface;
6+
use Ibexa\Discounts\Repository\DiscountCondition\DiscountConditionFactoryInterface;
7+
8+
final class IsAccountAnniversaryConditionFactory implements DiscountConditionFactoryInterface
9+
{
10+
public function createDiscountCondition(?array $expressionValues): DiscountConditionInterface
11+
{
12+
return new IsAccountAnniversary(
13+
$expressionValues['tolerance'] ?? null
14+
);
15+
}
16+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace App\Discounts\ExpressionProvider;
4+
5+
use Ibexa\Contracts\Core\Repository\PermissionResolver;
6+
use Ibexa\Contracts\Core\Repository\UserService;
7+
use Ibexa\Contracts\Discounts\DiscountVariablesResolverInterface;
8+
use Ibexa\Contracts\ProductCatalog\Values\Price\PriceContextInterface;
9+
10+
final class CurrentUserRegistrationDateResolver implements DiscountVariablesResolverInterface
11+
{
12+
private PermissionResolver $permissionResolver;
13+
14+
private UserService $userService;
15+
16+
public function __construct(PermissionResolver $permissionResolver, UserService $userService)
17+
{
18+
$this->permissionResolver = $permissionResolver;
19+
$this->userService = $userService;
20+
}
21+
22+
/**
23+
* @return array{current_user_registration_date: \DateTimeInterface}
24+
*/
25+
public function getVariables(PriceContextInterface $priceContext): array
26+
{
27+
return [
28+
'current_user_registration_date' => $this->userService->loadUser(
29+
$this->permissionResolver->getCurrentUserReference()->getUserId()
30+
)->getContentInfo()->publishedDate,
31+
];
32+
}
33+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace App\Discounts\ExpressionProvider;
4+
5+
use DateTimeImmutable;
6+
use DateTimeInterface;
7+
8+
final class IsAnniversaryResolver
9+
{
10+
private const YEAR_MONTH_DAY_FORMAT = 'Y-m-d';
11+
12+
private const MONTH_DAY_FORMAT = 'm-d';
13+
14+
private const REFERENCE_YEAR = 2000;
15+
16+
public function __invoke(DateTimeInterface $date, int $tolerance = 0): bool
17+
{
18+
$d1 = $this->unifyYear(new DateTimeImmutable());
19+
$d2 = $this->unifyYear($date);
20+
21+
$diff = $d1->diff($d2, true)->days;
22+
23+
// Check if the difference between dates is within the tolerance
24+
return $diff <= $tolerance;
25+
}
26+
27+
private function unifyYear(DateTimeInterface $date): DateTimeImmutable
28+
{
29+
// Create a new date using the reference year but with the same month and day
30+
$newDate = DateTimeImmutable::createFromFormat(
31+
self::YEAR_MONTH_DAY_FORMAT,
32+
self::REFERENCE_YEAR . '-' . $date->format(self::MONTH_DAY_FORMAT)
33+
);
34+
35+
if ($newDate === false) {
36+
throw new \RuntimeException('Failed to unify year for date.');
37+
}
38+
39+
return $newDate;
40+
}
41+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace App\Discounts;
4+
5+
use Ibexa\Contracts\Discounts\DiscountPrioritizationStrategyInterface;
6+
use Ibexa\Contracts\Discounts\Value\Query\SortClause\UpdatedAt;
7+
8+
final class RecentDiscountPrioritizationStrategy implements DiscountPrioritizationStrategyInterface
9+
{
10+
private DiscountPrioritizationStrategyInterface $inner;
11+
12+
public function __construct(DiscountPrioritizationStrategyInterface $inner)
13+
{
14+
$this->inner = $inner;
15+
}
16+
17+
public function getOrder(): array
18+
{
19+
return array_merge(
20+
[new UpdatedAt()],
21+
$this->inner->getOrder()
22+
);
23+
}
24+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Discounts\Rule;
6+
7+
use Ibexa\Contracts\Discounts\DiscountValueFormatterInterface;
8+
use Ibexa\Contracts\Discounts\Value\DiscountRuleInterface;
9+
use Money\Money;
10+
11+
final class PurchaseParityValueFormatter implements DiscountValueFormatterInterface
12+
{
13+
public function format(DiscountRuleInterface $discountRule, ?Money $money = null): string
14+
{
15+
return 'Regional discount';
16+
}
17+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace App\Discounts\Rule;
4+
5+
use Ibexa\Contracts\Discounts\Value\DiscountRuleInterface;
6+
use Ibexa\Discounts\Value\AbstractDiscountExpressionAware;
7+
8+
final class PurchasingPowerParityRule extends AbstractDiscountExpressionAware implements DiscountRuleInterface
9+
{
10+
public const TYPE = 'purchasing_power_parity';
11+
12+
private const DEFAULT_PARITY_MAP = [
13+
'default' => 100,
14+
'germany' => 81.6,
15+
'france' => 80,
16+
'spain' => 69,
17+
];
18+
19+
/** @param ?array<string, float> $powerParityMap */
20+
public function __construct(?array $powerParityMap = null)
21+
{
22+
parent::__construct(
23+
[
24+
'power_parity_map' => $powerParityMap ?? self::DEFAULT_PARITY_MAP,
25+
]
26+
);
27+
}
28+
29+
/** @return array<string, float> */
30+
public function getMap(): array
31+
{
32+
return $this->getExpressionValue('power_parity_map');
33+
}
34+
35+
public function getExpression(): string
36+
{
37+
return 'amount * (power_parity_map[get_current_region().getIdentifier()] / power_parity_map["default"])';
38+
}
39+
40+
public function getType(): string
41+
{
42+
return self::TYPE;
43+
}
44+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace App\Discounts\Rule;
4+
5+
use Ibexa\Contracts\Discounts\Value\DiscountRuleInterface;
6+
use Ibexa\Discounts\Repository\DiscountRule\DiscountRuleFactoryInterface;
7+
8+
final class PurchasingPowerParityRuleFactory implements DiscountRuleFactoryInterface
9+
{
10+
public function createDiscountRule(?array $expressionValues): DiscountRuleInterface
11+
{
12+
return new PurchasingPowerParityRule($expressionValues['power_parity_map'] ?? null);
13+
}
14+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace App\Discounts\Step;
4+
5+
use Ibexa\Contracts\Discounts\Admin\Form\Data\AbstractDiscountStep;
6+
7+
final class AnniversaryConditionStep extends AbstractDiscountStep
8+
{
9+
public const IDENTIFIER = 'anniversary_condition_step';
10+
11+
public bool $enabled;
12+
13+
public int $tolerance;
14+
15+
public function __construct(bool $enabled = false, int $tolerance = 0)
16+
{
17+
$this->enabled = $enabled;
18+
$this->tolerance = $tolerance;
19+
}
20+
}

0 commit comments

Comments
 (0)