Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions code_samples/discounts/src/Command/OrderPriceCommand.php
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(
Copy link
Contributor

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.

Copy link
Contributor Author

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)

$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
Copy link
Contributor

Choose a reason for hiding this comment

The 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 :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 2000, which is a leap year - on 29.02 it boils down 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.

Copy link
Contributor

Choose a reason for hiding this comment

The 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, 2024-02-29 gets anniversaries only every leap year.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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;
}
}
Loading
Loading