mirror of
https://github.com/FOSSBilling/FOSSBilling.git
synced 2026-10-23 03:37:01 +09:00
Add bundle-condition promos requiring a product set in the cart (#4401)
* Add bundle-condition promos requiring a product set in the cart * Trim assert helper docblock
This commit is contained in:
1 parent
7980877e63
commit
77fffad293
9 files changed
+651
-15
No files matched your search
@@ -541,6 +541,8 @@ class Service implements InjectionAwareInterface
|
|||||||
throw new \FOSSBilling\InformationException('Add products to your cart before applying promo code');
|
throw new \FOSSBilling\InformationException('Add products to your cart before applying promo code');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->assertPromoCartConditionMet($cart, $promo);
|
||||||
|
|
||||||
$cart->setPromoId($promoId);
|
$cart->setPromoId($promoId);
|
||||||
$this->persistCart($cart);
|
$this->persistCart($cart);
|
||||||
|
|
||||||
@@ -549,6 +551,44 @@ class Service implements InjectionAwareInterface
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throw when the promo's bundle condition is not met by the cart,
|
||||||
|
* naming the missing products.
|
||||||
|
*/
|
||||||
|
private function assertPromoCartConditionMet(Cart $cart, Promo $promo, ?array $cartProducts = null): void
|
||||||
|
{
|
||||||
|
$productService = $this->getProductService();
|
||||||
|
$missing = $productService->findMissingRequiredProductIds($promo, $this->getCartProductIds($cart, $cartProducts));
|
||||||
|
if ($missing === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$titles = [];
|
||||||
|
foreach ($productService->getProductSnapshotMap($missing) as $id => $snapshot) {
|
||||||
|
$titles[] = $snapshot['title'] ?? '#' . $id;
|
||||||
|
}
|
||||||
|
if ($titles === []) {
|
||||||
|
$titles = array_map(static fn (int $id): string => '#' . $id, $missing);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \FOSSBilling\InformationException('This promo code requires the following products in the cart: :products', [':products' => implode(', ', $titles)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw product ids on the cart rows, without resolving products.
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function getCartProductIds(Cart $cart, ?array $cartProducts = null): array
|
||||||
|
{
|
||||||
|
$ids = [];
|
||||||
|
foreach ($cartProducts ?? $this->getCartProducts($cart) as $cartProduct) {
|
||||||
|
$ids[] = (int) $cartProduct->getProductId();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ids;
|
||||||
|
}
|
||||||
|
|
||||||
protected function isEmptyCart(Cart $cart): bool
|
protected function isEmptyCart(Cart $cart): bool
|
||||||
{
|
{
|
||||||
$cartProducts = $this->findCartProducts($cart);
|
$cartProducts = $this->findCartProducts($cart);
|
||||||
@@ -647,7 +687,10 @@ class Service implements InjectionAwareInterface
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Promos in effect for a cart: the manual code when one is set, otherwise
|
* Promos in effect for a cart: the manual code when one is set, otherwise
|
||||||
* the eligible automatic promos for the (logged-in) client.
|
* the eligible automatic promos for the (logged-in) client. A manual code
|
||||||
|
* whose bundle condition the current lines no longer satisfy contributes
|
||||||
|
* no discount; checkout rejects it outright (fail-fast), so the cart can
|
||||||
|
* never drift into a discounted order.
|
||||||
*
|
*
|
||||||
* @return array{source: 'manual'|'auto'|null, promos: list<Promo>}
|
* @return array{source: 'manual'|'auto'|null, promos: list<Promo>}
|
||||||
*/
|
*/
|
||||||
@@ -655,9 +698,12 @@ class Service implements InjectionAwareInterface
|
|||||||
{
|
{
|
||||||
$promoId = $cart->getPromoId();
|
$promoId = $cart->getPromoId();
|
||||||
if ($promoId) {
|
if ($promoId) {
|
||||||
|
$promo = $this->getProductService()->findPromoById((int) $promoId);
|
||||||
|
$conditionMet = $this->getProductService()->findMissingRequiredProductIds($promo, $this->getCartProductIds($cart, $cartProducts)) === [];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'source' => 'manual',
|
'source' => 'manual',
|
||||||
'promos' => [$this->getProductService()->findPromoById((int) $promoId)],
|
'promos' => $conditionMet ? [$promo] : [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -837,6 +883,8 @@ class Service implements InjectionAwareInterface
|
|||||||
if (!$this->isPromoAvailableForClientGroup($promo)) {
|
if (!$this->isPromoAvailableForClientGroup($promo)) {
|
||||||
throw new \FOSSBilling\InformationException('Promo code cannot be applied to your account');
|
throw new \FOSSBilling\InformationException('Promo code cannot be applied to your account');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->assertPromoCartConditionMet($cart, $promo);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->di['events_manager']->fire(
|
$this->di['events_manager']->fire(
|
||||||
@@ -919,6 +967,8 @@ class Service implements InjectionAwareInterface
|
|||||||
if (!$this->isPromoAvailableForClientGroup($promo, $client)) {
|
if (!$this->isPromoAvailableForClientGroup($promo, $client)) {
|
||||||
throw new \FOSSBilling\InformationException('Promo code cannot be applied to this client account');
|
throw new \FOSSBilling\InformationException('Promo code cannot be applied to this client account');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->assertPromoCartConditionMet($basket, $promo);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->di['events_manager']->fire(
|
$this->di['events_manager']->fire(
|
||||||
@@ -1035,6 +1085,12 @@ class Service implements InjectionAwareInterface
|
|||||||
|
|
||||||
$cartProducts = $this->getCartProducts($cart);
|
$cartProducts = $this->getCartProducts($cart);
|
||||||
|
|
||||||
|
if ($effectivePromos !== []) {
|
||||||
|
// The cart may have changed since the code was applied;
|
||||||
|
// re-check the bundle condition inside the transaction.
|
||||||
|
$this->assertPromoCartConditionMet($cart, $effectivePromos[0], $cartProducts);
|
||||||
|
}
|
||||||
|
|
||||||
if ($effectivePromos === []) {
|
if ($effectivePromos === []) {
|
||||||
// A manual code suppresses automatic promos; resolve here
|
// A manual code suppresses automatic promos; resolve here
|
||||||
// so totals, reservations, and redemptions share one set.
|
// so totals, reservations, and redemptions share one set.
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ use Symfony\Component\HttpFoundation\Request;
|
|||||||
|
|
||||||
use function Tests\Helpers\container;
|
use function Tests\Helpers\container;
|
||||||
use function Tests\Helpers\createEntity;
|
use function Tests\Helpers\createEntity;
|
||||||
|
use function Tests\Helpers\moduleService;
|
||||||
|
|
||||||
function createProductEntity(?int $id = null, ?string $type = null, ?string $config = null): Product
|
function createProductEntity(?int $id = null, ?string $type = null, ?string $config = null): Product
|
||||||
{
|
{
|
||||||
@@ -465,9 +466,13 @@ test('applyPromo returns true', function (): void {
|
|||||||
|
|
||||||
$promo = createPromoEntity(2);
|
$promo = createPromoEntity(2);
|
||||||
|
|
||||||
|
$productService = Mockery::mock(ProductService::class);
|
||||||
|
$productService->shouldReceive('findMissingRequiredProductIds')->once()->with($promo, [0, 0])->andReturn([]);
|
||||||
|
|
||||||
$di = container();
|
$di = container();
|
||||||
$di['em'] = $emMock;
|
$di['em'] = $emMock;
|
||||||
$di['logger'] = new Tests\Helpers\TestLogger();
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
||||||
|
$di['mod_service'] = $di->protect(moduleService(['product' => $productService]));
|
||||||
$service = new Service();
|
$service = new Service();
|
||||||
$service->setDi($di);
|
$service->setDi($di);
|
||||||
|
|
||||||
@@ -520,6 +525,38 @@ test('applyPromo throws exception when cart is empty', function (): void {
|
|||||||
expect(fn (): bool => $service->applyPromo($cart, $promo))->toThrow(FOSSBilling\Exception::class);
|
expect(fn (): bool => $service->applyPromo($cart, $promo))->toThrow(FOSSBilling\Exception::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('applyPromo rejects a code whose bundle condition is unmet', function (): void {
|
||||||
|
$cartProduct = new CartProduct();
|
||||||
|
$cartProduct->setProductId(5);
|
||||||
|
|
||||||
|
$cartProductRepo = Mockery::mock(CartProductRepository::class);
|
||||||
|
$cartProductRepo->shouldReceive('findByCartId')->atLeast()->once()->with(1)->andReturn([$cartProduct]);
|
||||||
|
|
||||||
|
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
|
||||||
|
$emMock->shouldReceive('getRepository')->with(CartProduct::class)->andReturn($cartProductRepo);
|
||||||
|
$emMock->shouldNotReceive('persist');
|
||||||
|
|
||||||
|
$promo = createPromoEntity(2);
|
||||||
|
|
||||||
|
$productService = Mockery::mock(ProductService::class);
|
||||||
|
$productService->shouldReceive('findMissingRequiredProductIds')->once()->with($promo, [5])->andReturn([9]);
|
||||||
|
$productService->shouldReceive('getProductSnapshotMap')->once()->with([9])->andReturn([9 => ['id' => 9, 'title' => 'Addon Nine', 'type' => 'addon', 'plugin' => 'custom']]);
|
||||||
|
|
||||||
|
$cart = new Cart();
|
||||||
|
$cartReflection = new ReflectionProperty($cart, 'id');
|
||||||
|
$cartReflection->setValue($cart, 1);
|
||||||
|
|
||||||
|
$di = container();
|
||||||
|
$di['em'] = $emMock;
|
||||||
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
||||||
|
$di['mod_service'] = $di->protect(moduleService(['product' => $productService]));
|
||||||
|
$service = new Service();
|
||||||
|
$service->setDi($di);
|
||||||
|
|
||||||
|
expect(fn (): bool => $service->applyPromo($cart, $promo))
|
||||||
|
->toThrow(FOSSBilling\InformationException::class, 'This promo code requires the following products in the cart: Addon Nine');
|
||||||
|
});
|
||||||
|
|
||||||
test('rm returns true', function (): void {
|
test('rm returns true', function (): void {
|
||||||
$cart = new Cart();
|
$cart = new Cart();
|
||||||
$cartReflection = new ReflectionProperty($cart, 'id');
|
$cartReflection = new ReflectionProperty($cart, 'id');
|
||||||
@@ -676,6 +713,7 @@ test('checkoutCart returns array with expected keys', function (): void {
|
|||||||
$serviceMock->shouldReceive('isClientAbleToUsePromo')->atLeast()->once()->andReturn(true);
|
$serviceMock->shouldReceive('isClientAbleToUsePromo')->atLeast()->once()->andReturn(true);
|
||||||
$serviceMock->shouldReceive('rm')->atLeast()->once()->andReturn(true);
|
$serviceMock->shouldReceive('rm')->atLeast()->once()->andReturn(true);
|
||||||
$serviceMock->shouldReceive('isPromoAvailableForClientGroup')->atLeast()->once()->andReturn(true);
|
$serviceMock->shouldReceive('isPromoAvailableForClientGroup')->atLeast()->once()->andReturn(true);
|
||||||
|
$serviceMock->shouldReceive('getCartProducts')->once()->with($cart)->andReturn([]);
|
||||||
|
|
||||||
$eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing();
|
$eventMock = Mockery::mock(Box_EventManager::class)->shouldIgnoreMissing();
|
||||||
$eventMock->shouldReceive('fire')->atLeast()->once();
|
$eventMock->shouldReceive('fire')->atLeast()->once();
|
||||||
@@ -690,6 +728,7 @@ test('checkoutCart returns array with expected keys', function (): void {
|
|||||||
|
|
||||||
$productService = Mockery::mock(ProductService::class);
|
$productService = Mockery::mock(ProductService::class);
|
||||||
$productService->shouldReceive('findPromoById')->once()->with(1)->andReturn($promo);
|
$productService->shouldReceive('findPromoById')->once()->with(1)->andReturn($promo);
|
||||||
|
$productService->shouldReceive('findMissingRequiredProductIds')->once()->with($promo, [])->andReturn([]);
|
||||||
|
|
||||||
$di = container();
|
$di = container();
|
||||||
$di['events_manager'] = $eventMock;
|
$di['events_manager'] = $eventMock;
|
||||||
@@ -827,6 +866,7 @@ test('createFromCart with promo entity uses product promo service', function ():
|
|||||||
$productService = Mockery::mock(ProductService::class);
|
$productService = Mockery::mock(ProductService::class);
|
||||||
$productService->shouldReceive('findPromoById')->once()->with(7)->andReturn($promo);
|
$productService->shouldReceive('findPromoById')->once()->with(7)->andReturn($promo);
|
||||||
$productService->shouldReceive('clientHasActivePromoApplicationForUpdate')->once()->with($client, $promo)->andReturn(false);
|
$productService->shouldReceive('clientHasActivePromoApplicationForUpdate')->once()->with($client, $promo)->andReturn(false);
|
||||||
|
$productService->shouldReceive('findMissingRequiredProductIds')->once()->with($promo, [0])->andReturn([]);
|
||||||
$productService->shouldReceive('reserveStockForOrder')->once()->with(Mockery::type(Order::class));
|
$productService->shouldReceive('reserveStockForOrder')->once()->with(Mockery::type(Order::class));
|
||||||
$productService->shouldReceive('reservePromosForOrder')->once()->with([$promo], Mockery::type(Order::class));
|
$productService->shouldReceive('reservePromosForOrder')->once()->with([$promo], Mockery::type(Order::class));
|
||||||
$productService->shouldReceive('createCheckoutPromoRedemptionsForPromos')->once()->with(
|
$productService->shouldReceive('createCheckoutPromoRedemptionsForPromos')->once()->with(
|
||||||
@@ -1378,6 +1418,7 @@ test('createFromCart compensates promo usage on transaction failure', function (
|
|||||||
$productService = Mockery::mock(ProductService::class);
|
$productService = Mockery::mock(ProductService::class);
|
||||||
$productService->shouldReceive('findPromoById')->once()->with(7)->andReturn($promo);
|
$productService->shouldReceive('findPromoById')->once()->with(7)->andReturn($promo);
|
||||||
$productService->shouldReceive('clientHasActivePromoApplicationForUpdate')->once()->with($client, $promo)->andReturn(false);
|
$productService->shouldReceive('clientHasActivePromoApplicationForUpdate')->once()->with($client, $promo)->andReturn(false);
|
||||||
|
$productService->shouldReceive('findMissingRequiredProductIds')->once()->with($promo, [0])->andReturn([]);
|
||||||
$productService->shouldReceive('reserveStockForOrder')->once()->with(Mockery::type(Order::class));
|
$productService->shouldReceive('reserveStockForOrder')->once()->with(Mockery::type(Order::class));
|
||||||
$productService->shouldReceive('reservePromosForOrder')->once()->with([$promo], Mockery::type(Order::class));
|
$productService->shouldReceive('reservePromosForOrder')->once()->with([$promo], Mockery::type(Order::class));
|
||||||
$productService->shouldReceive('createCheckoutPromoRedemptionsForPromos')
|
$productService->shouldReceive('createCheckoutPromoRedemptionsForPromos')
|
||||||
@@ -1481,6 +1522,7 @@ test('createFromCart releases reserved stock on transaction failure', function (
|
|||||||
$productService = Mockery::mock(ProductService::class);
|
$productService = Mockery::mock(ProductService::class);
|
||||||
$productService->shouldReceive('findPromoById')->once()->with(7)->andReturn($promo);
|
$productService->shouldReceive('findPromoById')->once()->with(7)->andReturn($promo);
|
||||||
$productService->shouldReceive('clientHasActivePromoApplicationForUpdate')->once()->with($client, $promo)->andReturn(false);
|
$productService->shouldReceive('clientHasActivePromoApplicationForUpdate')->once()->with($client, $promo)->andReturn(false);
|
||||||
|
$productService->shouldReceive('findMissingRequiredProductIds')->once()->with($promo, [0])->andReturn([]);
|
||||||
$productService->shouldReceive('reserveStockForOrder')->once()->with(Mockery::type(Order::class));
|
$productService->shouldReceive('reserveStockForOrder')->once()->with(Mockery::type(Order::class));
|
||||||
$productService->shouldReceive('reservePromosForOrder')->once()->with([$promo], Mockery::type(Order::class));
|
$productService->shouldReceive('reservePromosForOrder')->once()->with([$promo], Mockery::type(Order::class));
|
||||||
|
|
||||||
@@ -2530,19 +2572,44 @@ test('getEffectiveCartPromos returns the manual promo when a code is set', funct
|
|||||||
|
|
||||||
$productService = Mockery::mock(ProductService::class);
|
$productService = Mockery::mock(ProductService::class);
|
||||||
$productService->shouldReceive('findPromoById')->once()->with(7)->andReturn($promo);
|
$productService->shouldReceive('findPromoById')->once()->with(7)->andReturn($promo);
|
||||||
|
$productService->shouldReceive('findMissingRequiredProductIds')->once()->with($promo, [])->andReturn([]);
|
||||||
|
|
||||||
$di = container();
|
$di = container();
|
||||||
$di['mod_service'] = $di->protect(fn () => $productService);
|
$di['mod_service'] = $di->protect(fn () => $productService);
|
||||||
|
|
||||||
$service = new Service();
|
$serviceMock = Mockery::mock(Service::class)->makePartial();
|
||||||
$service->setDi($di);
|
$serviceMock->shouldReceive('getCartProducts')->once()->with($cart)->andReturn([]);
|
||||||
|
$serviceMock->setDi($di);
|
||||||
|
|
||||||
$result = $service->getEffectiveCartPromos($cart);
|
$result = $serviceMock->getEffectiveCartPromos($cart);
|
||||||
|
|
||||||
expect($result['source'])->toBe('manual');
|
expect($result['source'])->toBe('manual');
|
||||||
expect($result['promos'])->toBe([$promo]);
|
expect($result['promos'])->toBe([$promo]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getEffectiveCartPromos drops a manual promo whose bundle condition is unmet', function (): void {
|
||||||
|
$cart = createEntity(Cart::class);
|
||||||
|
$cart->promo_id = 7;
|
||||||
|
|
||||||
|
$promo = createPromoEntity(7);
|
||||||
|
|
||||||
|
$productService = Mockery::mock(ProductService::class);
|
||||||
|
$productService->shouldReceive('findPromoById')->once()->with(7)->andReturn($promo);
|
||||||
|
$productService->shouldReceive('findMissingRequiredProductIds')->once()->with($promo, [])->andReturn([5]);
|
||||||
|
|
||||||
|
$di = container();
|
||||||
|
$di['mod_service'] = $di->protect(fn () => $productService);
|
||||||
|
|
||||||
|
$serviceMock = Mockery::mock(Service::class)->makePartial();
|
||||||
|
$serviceMock->shouldReceive('getCartProducts')->once()->with($cart)->andReturn([]);
|
||||||
|
$serviceMock->setDi($di);
|
||||||
|
|
||||||
|
$result = $serviceMock->getEffectiveCartPromos($cart);
|
||||||
|
|
||||||
|
expect($result['source'])->toBe('manual');
|
||||||
|
expect($result['promos'])->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('getEffectiveCartPromos resolves automatic promos for a logged-in client', function (): void {
|
test('getEffectiveCartPromos resolves automatic promos for a logged-in client', function (): void {
|
||||||
$cart = createEntity(Cart::class);
|
$cart = createEntity(Cart::class);
|
||||||
|
|
||||||
|
|||||||
@@ -433,6 +433,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
|||||||
*
|
*
|
||||||
* @optional array $products - list of product ids for which this promo code applies
|
* @optional array $products - list of product ids for which this promo code applies
|
||||||
* @optional array $periods - list of period codes
|
* @optional array $periods - list of period codes
|
||||||
|
* @optional array $requires_products - list of product ids that must all be in the cart (bundle condition)
|
||||||
* @optional bool $active - flag to enable/disable promo code
|
* @optional bool $active - flag to enable/disable promo code
|
||||||
* @optional bool $freesetup - flag to enable/disable free setup price
|
* @optional bool $freesetup - flag to enable/disable free setup price
|
||||||
* @optional bool $once_per_client - flag to enable/disable promo code usage once per client
|
* @optional bool $once_per_client - flag to enable/disable promo code usage once per client
|
||||||
@@ -470,9 +471,14 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
|||||||
if (isset($data['client_groups']) && is_array($data['client_groups'])) {
|
if (isset($data['client_groups']) && is_array($data['client_groups'])) {
|
||||||
$clientGroups = $data['client_groups'];
|
$clientGroups = $data['client_groups'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$requiresProducts = [];
|
||||||
|
if (isset($data['requires_products']) && is_array($data['requires_products'])) {
|
||||||
|
$requiresProducts = $data['requires_products'];
|
||||||
|
}
|
||||||
$service = $this->getService();
|
$service = $this->getService();
|
||||||
|
|
||||||
return (int) $service->createPromo($data['code'], $data['type'], $data['value'], $products, $periods, $clientGroups, $data);
|
return (int) $service->createPromo($data['code'], $data['type'], $data['value'], $products, $periods, $clientGroups, $requiresProducts, $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -550,6 +556,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
|||||||
* @optional string $value - promo code value. Percents or discount amount in currency
|
* @optional string $value - promo code value. Percents or discount amount in currency
|
||||||
* @optional array $products - list of product ids for which this promo code applies
|
* @optional array $products - list of product ids for which this promo code applies
|
||||||
* @optional array $periods - list of period codes
|
* @optional array $periods - list of period codes
|
||||||
|
* @optional array $requires_products - list of product ids that must all be in the cart (bundle condition)
|
||||||
* @optional bool $active - flag to enable/disable promo code
|
* @optional bool $active - flag to enable/disable promo code
|
||||||
* @optional bool $freesetup - flag to enable/disable free setup price
|
* @optional bool $freesetup - flag to enable/disable free setup price
|
||||||
* @optional bool $once_per_client - flag to enable/disable promo code usage once per client
|
* @optional bool $once_per_client - flag to enable/disable promo code usage once per client
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ class Promo implements ApiArrayInterface, TimestampInterface
|
|||||||
#[ORM\Column(name: 'client_groups', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: true)]
|
#[ORM\Column(name: 'client_groups', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: true)]
|
||||||
private ?string $clientGroups = null;
|
private ?string $clientGroups = null;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'requires_products', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: true)]
|
||||||
|
private ?string $requiresProducts = null;
|
||||||
|
|
||||||
#[ORM\Column(name: 'start_at', type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
|
#[ORM\Column(name: 'start_at', type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
|
||||||
private ?\DateTime $startAt = null;
|
private ?\DateTime $startAt = null;
|
||||||
|
|
||||||
@@ -117,6 +120,7 @@ class Promo implements ApiArrayInterface, TimestampInterface
|
|||||||
'products' => $this->products,
|
'products' => $this->products,
|
||||||
'periods' => $this->periods,
|
'periods' => $this->periods,
|
||||||
'client_groups' => $this->clientGroups,
|
'client_groups' => $this->clientGroups,
|
||||||
|
'requires_products' => $this->requiresProducts,
|
||||||
'start_at' => $this->startAt?->format('Y-m-d H:i:s'),
|
'start_at' => $this->startAt?->format('Y-m-d H:i:s'),
|
||||||
'end_at' => $this->endAt?->format('Y-m-d H:i:s'),
|
'end_at' => $this->endAt?->format('Y-m-d H:i:s'),
|
||||||
'auto_apply' => $this->autoApply ?? false,
|
'auto_apply' => $this->autoApply ?? false,
|
||||||
@@ -288,6 +292,18 @@ class Promo implements ApiArrayInterface, TimestampInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getRequiresProducts(): ?string
|
||||||
|
{
|
||||||
|
return $this->requiresProducts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRequiresProducts(?string $requiresProducts): self
|
||||||
|
{
|
||||||
|
$this->requiresProducts = $requiresProducts;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getStartAt(): ?\DateTime
|
public function getStartAt(): ?\DateTime
|
||||||
{
|
{
|
||||||
return $this->startAt;
|
return $this->startAt;
|
||||||
|
|||||||
@@ -1252,7 +1252,7 @@ class Service implements InjectionAwareInterface
|
|||||||
return $this->getPromoRepository()->getSearchQueryBuilder($data);
|
return $this->getPromoRepository()->getSearchQueryBuilder($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createPromo($code, $type, $value, $products, $periods, $clientGroups, $data): int
|
public function createPromo($code, $type, $value, $products, $periods, $clientGroups, $requiresProducts, $data): int
|
||||||
{
|
{
|
||||||
if ($this->getPromoRepository()->findOneBy(['code' => $code]) instanceof Promo) {
|
if ($this->getPromoRepository()->findOneBy(['code' => $code]) instanceof Promo) {
|
||||||
throw new \FOSSBilling\InformationException('This promotion code already exists.');
|
throw new \FOSSBilling\InformationException('This promotion code already exists.');
|
||||||
@@ -1267,6 +1267,7 @@ class Service implements InjectionAwareInterface
|
|||||||
'products' => $products,
|
'products' => $products,
|
||||||
'periods' => $periods,
|
'periods' => $periods,
|
||||||
'client_groups' => $clientGroups,
|
'client_groups' => $clientGroups,
|
||||||
|
'requires_products' => $requiresProducts,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->di['em']->persist($promo);
|
$this->di['em']->persist($promo);
|
||||||
@@ -1298,6 +1299,7 @@ class Service implements InjectionAwareInterface
|
|||||||
->setProducts($model->getProducts())
|
->setProducts($model->getProducts())
|
||||||
->setPeriods($model->getPeriods())
|
->setPeriods($model->getPeriods())
|
||||||
->setClientGroups($model->getClientGroups())
|
->setClientGroups($model->getClientGroups())
|
||||||
|
->setRequiresProducts($model->getRequiresProducts())
|
||||||
->setStartAt($model->getStartAt() !== null ? clone $model->getStartAt() : null)
|
->setStartAt($model->getStartAt() !== null ? clone $model->getStartAt() : null)
|
||||||
->setEndAt($model->getEndAt() !== null ? clone $model->getEndAt() : null);
|
->setEndAt($model->getEndAt() !== null ? clone $model->getEndAt() : null);
|
||||||
|
|
||||||
@@ -1639,6 +1641,10 @@ class Service implements InjectionAwareInterface
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!$this->isPromoCartConditionMet($promo, $lines)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$discount = 0.0;
|
$discount = 0.0;
|
||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
$product = $line['product'];
|
$product = $line['product'];
|
||||||
@@ -2027,13 +2033,16 @@ class Service implements InjectionAwareInterface
|
|||||||
public function enrichPromoApiArray(array $result, $deep = false, $identity = null): array
|
public function enrichPromoApiArray(array $result, $deep = false, $identity = null): array
|
||||||
{
|
{
|
||||||
$products = !empty($result['products']) ? $this->getProductTitlesByIds($this->decodePromoSelection($result['products'])) : null;
|
$products = !empty($result['products']) ? $this->getProductTitlesByIds($this->decodePromoSelection($result['products'])) : null;
|
||||||
|
$requiredProducts = !empty($result['requires_products']) ? $this->getProductTitlesByIds($this->decodePromoSelection($result['requires_products'])) : null;
|
||||||
$clientGroups = !empty($result['client_groups']) ? $this->di['tools']->getPairsForTableByIds('client_group', $this->decodePromoSelection($result['client_groups'])) : null;
|
$clientGroups = !empty($result['client_groups']) ? $this->di['tools']->getPairsForTableByIds('client_group', $this->decodePromoSelection($result['client_groups'])) : null;
|
||||||
$usageStats = $deep ? $this->getPromoUsageStatsByValues((int) $result['id'], (int) ($result['used'] ?? 0), isset($result['maxuses']) ? (int) $result['maxuses'] : null) : null;
|
$usageStats = $deep ? $this->getPromoUsageStatsByValues((int) $result['id'], (int) ($result['used'] ?? 0), isset($result['maxuses']) ? (int) $result['maxuses'] : null) : null;
|
||||||
$redemptionCount = $usageStats['recorded_applications'] ?? $this->getPromoRedemptionCountById((int) $result['id']);
|
$redemptionCount = $usageStats['recorded_applications'] ?? $this->getPromoRedemptionCountById((int) $result['id']);
|
||||||
|
|
||||||
$result['applies_to'] = $products;
|
$result['applies_to'] = $products;
|
||||||
|
$result['requires'] = $requiredProducts;
|
||||||
$result['cgroups'] = $clientGroups;
|
$result['cgroups'] = $clientGroups;
|
||||||
$result['products'] = $this->decodePromoSelection($result['products'] ?? null);
|
$result['products'] = $this->decodePromoSelection($result['products'] ?? null);
|
||||||
|
$result['requires_products'] = $this->decodePromoSelection($result['requires_products'] ?? null);
|
||||||
$result['periods'] = $this->decodePromoSelection($result['periods'] ?? null);
|
$result['periods'] = $this->decodePromoSelection($result['periods'] ?? null);
|
||||||
$result['client_groups'] = $this->decodePromoSelection($result['client_groups'] ?? null);
|
$result['client_groups'] = $this->decodePromoSelection($result['client_groups'] ?? null);
|
||||||
$result['redemption_count'] = $redemptionCount;
|
$result['redemption_count'] = $redemptionCount;
|
||||||
@@ -2567,6 +2576,55 @@ class Service implements InjectionAwareInterface
|
|||||||
return $this->isPromoApplicableToProduct($promo, $this->findProductById($productId), $config);
|
return $this->isPromoApplicableToProduct($promo, $this->findProductById($productId), $config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Product ids that must all be present in the cart for the promo's
|
||||||
|
* bundle condition to hold. Empty means no condition.
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
public function getPromoRequiredProducts(Promo $promo): array
|
||||||
|
{
|
||||||
|
return $this->normalizeProductIds($this->decodePromoSelection($this->getPromoSourceArray($promo)['requires_products'] ?? null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Required product ids missing from the given cart product ids.
|
||||||
|
*
|
||||||
|
* @param list<int> $productIds
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
public function findMissingRequiredProductIds(Promo $promo, array $productIds): array
|
||||||
|
{
|
||||||
|
$required = $this->getPromoRequiredProducts($promo);
|
||||||
|
if ($required === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$present = array_map(intval(...), $productIds);
|
||||||
|
|
||||||
|
return array_values(array_diff($required, $present));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the cart lines satisfy the promo's bundle condition.
|
||||||
|
*
|
||||||
|
* @param list<array{product?: Product, config?: array}> $lines
|
||||||
|
*/
|
||||||
|
public function isPromoCartConditionMet(Promo $promo, array $lines): bool
|
||||||
|
{
|
||||||
|
$productIds = [];
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$product = $line['product'] ?? null;
|
||||||
|
$id = $product instanceof Product ? $product->getId() : null;
|
||||||
|
if ($id !== null) {
|
||||||
|
$productIds[] = $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->findMissingRequiredProductIds($promo, $productIds) === [];
|
||||||
|
}
|
||||||
|
|
||||||
public function getProductDiscount(Product $product, Promo $promo, ?array $config = null)
|
public function getProductDiscount(Product $product, Promo $promo, ?array $config = null)
|
||||||
{
|
{
|
||||||
if (!$this->isPromoApplicableToProduct($promo, $product, $config)) {
|
if (!$this->isPromoApplicableToProduct($promo, $product, $config)) {
|
||||||
@@ -3025,6 +3083,7 @@ class Service implements InjectionAwareInterface
|
|||||||
->setProducts($this->encodePromoSelection($data['products'] ?? $this->decodePromoSelection($promo->getProducts())))
|
->setProducts($this->encodePromoSelection($data['products'] ?? $this->decodePromoSelection($promo->getProducts())))
|
||||||
->setPeriods($this->encodePromoSelection($data['periods'] ?? $this->decodePromoSelection($promo->getPeriods())))
|
->setPeriods($this->encodePromoSelection($data['periods'] ?? $this->decodePromoSelection($promo->getPeriods())))
|
||||||
->setClientGroups($this->encodePromoSelection($data['client_groups'] ?? $this->decodePromoSelection($promo->getClientGroups())))
|
->setClientGroups($this->encodePromoSelection($data['client_groups'] ?? $this->decodePromoSelection($promo->getClientGroups())))
|
||||||
|
->setRequiresProducts($this->encodePromoSelection($this->normalizeProductIds((array) ($data['requires_products'] ?? $this->decodePromoSelection($promo->getRequiresProducts())))))
|
||||||
->setStartAt($this->normalizePromoDateTimeObject($data['start_at'] ?? $promo->getStartAt()))
|
->setStartAt($this->normalizePromoDateTimeObject($data['start_at'] ?? $promo->getStartAt()))
|
||||||
->setEndAt($this->normalizePromoDateTimeObject($data['end_at'] ?? $promo->getEndAt()));
|
->setEndAt($this->normalizePromoDateTimeObject($data['end_at'] ?? $promo->getEndAt()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -320,27 +320,35 @@
|
|||||||
|
|
||||||
<hr class="my-4">
|
<hr class="my-4">
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-3">
|
||||||
<label class="form-label">{{ 'Applicable Products (Select None to Apply to All)'|trans }}</label>
|
<label class="form-label">{{ 'Applicable Products (Select None to Apply to All)'|trans }}</label>
|
||||||
<input type="hidden" name="products[]" value=""/>
|
<input type="hidden" name="products[]" value=""/>
|
||||||
<table class="table card-table table-vcenter table-striped text-nowrap">
|
<table class="table card-table table-vcenter table-striped text-nowrap">
|
||||||
<tbody>{% set products = admin.product_get_pairs %}{% for id,product in products %}<tr><td class="w-1"><input type="checkbox" name="products[]" value="{{ id }}" {% if id in promo.products %} checked{% endif %} /></td><td>{{ product }}</td></tr>{% endfor %}</tbody>
|
<tbody>{% set products = admin.product_get_pairs %}{% for id,product in products %}<tr><td class="w-1"><input type="checkbox" name="products[]" value="{{ id }}" {% if id in promo.products %} checked{% endif %} /></td><td>{{ product }}</td></tr>{% endfor %}</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-3">
|
||||||
<label class="form-label">{{ 'Applicable Periods (Select None to Apply to All)'|trans }}</label>
|
<label class="form-label">{{ 'Applicable Periods (Select None to Apply to All)'|trans }}</label>
|
||||||
<input type="hidden" name="periods[]" value=""/>
|
<input type="hidden" name="periods[]" value=""/>
|
||||||
<table class="table card-table table-vcenter table-striped text-nowrap">
|
<table class="table card-table table-vcenter table-striped text-nowrap">
|
||||||
<tbody>{% set periods = guest.system_periods %}{% for id, period in periods %}<tr><td class="w-1"><input type="checkbox" name="periods[]" value="{{ id }}" {% if id in promo.periods %} checked{% endif %} /></td><td>{{ period }}</td></tr>{% endfor %}</tbody>
|
<tbody>{% set periods = guest.system_periods %}{% for id, period in periods %}<tr><td class="w-1"><input type="checkbox" name="periods[]" value="{{ id }}" {% if id in promo.periods %} checked{% endif %} /></td><td>{{ period }}</td></tr>{% endfor %}</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-3">
|
||||||
<label class="form-label">{{ 'Applicable Client Groups (Select None to Apply to All)'|trans }}</label>
|
<label class="form-label">{{ 'Applicable Client Groups (Select None to Apply to All)'|trans }}</label>
|
||||||
<input type="hidden" name="client_groups[]" value=""/>
|
<input type="hidden" name="client_groups[]" value=""/>
|
||||||
<table class="table card-table table-vcenter table-striped text-nowrap">
|
<table class="table card-table table-vcenter table-striped text-nowrap">
|
||||||
<tbody>{% set client_groups = admin.client_group_get_pairs %}{% for id, client_group in client_groups %}<tr><td class="w-1"><input type="checkbox" name="client_groups[]" value="{{ id }}" {% if id in promo.client_groups %} checked{% endif %} /></td><td>{{ client_group }}</td></tr>{% endfor %}</tbody>
|
<tbody>{% set client_groups = admin.client_group_get_pairs %}{% for id, client_group in client_groups %}<tr><td class="w-1"><input type="checkbox" name="client_groups[]" value="{{ id }}" {% if id in promo.client_groups %} checked{% endif %} /></td><td>{{ client_group }}</td></tr>{% endfor %}</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-lg-3">
|
||||||
|
<label class="form-label">{{ 'Required Products (Select None for No Condition)'|trans }}</label>
|
||||||
|
<input type="hidden" name="requires_products[]" value=""/>
|
||||||
|
<table class="table card-table table-vcenter table-striped text-nowrap">
|
||||||
|
<tbody>{% set required_products = admin.product_get_pairs %}{% for id, required_product in required_products %}<tr><td class="w-1"><input type="checkbox" name="requires_products[]" value="{{ id }}" {% if id in promo.requires_products %} checked{% endif %} /></td><td>{{ required_product }}</td></tr>{% endfor %}</tbody>
|
||||||
|
</table>
|
||||||
|
<small class="form-hint">{{ 'The promotion only applies when every selected product is in the cart.'|trans }}</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="id" value="{{ promo.id }}">
|
<input type="hidden" name="id" value="{{ promo.id }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -331,10 +331,10 @@
|
|||||||
<div class="fb-modal-section-heading-row">
|
<div class="fb-modal-section-heading-row">
|
||||||
<h4 class="fb-modal-section-title">{{ 'Applicability'|trans }}</h4>
|
<h4 class="fb-modal-section-title">{{ 'Applicability'|trans }}</h4>
|
||||||
</div>
|
</div>
|
||||||
<p class="fb-modal-section-description">{{ 'Restrict the promotion to specific products, billing periods, or client groups, or leave each list empty to apply globally.'|trans }}</p>
|
<p class="fb-modal-section-description">{{ 'Restrict the promotion to specific products, billing periods, or client groups, require a set of products in the cart, or leave each list empty to apply globally.'|trans }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-xl-4">
|
<div class="col-xl-3">
|
||||||
<label class="form-label">{{ 'Applicable Products'|trans }}</label>
|
<label class="form-label">{{ 'Applicable Products'|trans }}</label>
|
||||||
<input type="hidden" name="products[]" value="">
|
<input type="hidden" name="products[]" value="">
|
||||||
<div class="table-responsive border rounded-3" style="max-height: 16rem;">
|
<div class="table-responsive border rounded-3" style="max-height: 16rem;">
|
||||||
@@ -348,7 +348,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-xl-4">
|
<div class="col-xl-3">
|
||||||
<label class="form-label">{{ 'Applicable Periods'|trans }}</label>
|
<label class="form-label">{{ 'Applicable Periods'|trans }}</label>
|
||||||
<input type="hidden" name="periods[]" value="">
|
<input type="hidden" name="periods[]" value="">
|
||||||
<div class="table-responsive border rounded-3" style="max-height: 16rem;">
|
<div class="table-responsive border rounded-3" style="max-height: 16rem;">
|
||||||
@@ -362,7 +362,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-xl-4">
|
<div class="col-xl-3">
|
||||||
<label class="form-label">{{ 'Applicable Client Groups'|trans }}</label>
|
<label class="form-label">{{ 'Applicable Client Groups'|trans }}</label>
|
||||||
<input type="hidden" name="client_groups[]" value="">
|
<input type="hidden" name="client_groups[]" value="">
|
||||||
<div class="table-responsive border rounded-3" style="max-height: 16rem;">
|
<div class="table-responsive border rounded-3" style="max-height: 16rem;">
|
||||||
@@ -376,6 +376,21 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-xl-3">
|
||||||
|
<label class="form-label">{{ 'Required Products'|trans }}</label>
|
||||||
|
<input type="hidden" name="requires_products[]" value="">
|
||||||
|
<div class="table-responsive border rounded-3" style="max-height: 16rem;">
|
||||||
|
<table class="table card-table table-vcenter table-striped text-nowrap mb-0">
|
||||||
|
<tbody>
|
||||||
|
{% set required_products = admin.product_get_pairs %}
|
||||||
|
{% for id, required_product in required_products %}
|
||||||
|
<tr><td class="w-1"><input type="checkbox" name="requires_products[]" value="{{ id }}"></td><td>{{ required_product }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<small class="form-hint">{{ 'The promotion only applies when every selected product is in the cart.'|trans }}</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copyright 2022-2026 FOSSBilling
|
||||||
|
* SPDX-License-Identifier: Apache-2.0.
|
||||||
|
*
|
||||||
|
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||||
|
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Skip E2E tests if environment is not configured
|
||||||
|
if (!getenv('APP_URL') || !getenv('TEST_API_KEY')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
use function Tests\Helpers\assertApiResultIsArray;
|
||||||
|
use function Tests\Helpers\assertApiResultIsInt;
|
||||||
|
use function Tests\Helpers\assertApiSuccess;
|
||||||
|
|
||||||
|
test('bundle promo code is rejected until every required product is in the cart', function (): void {
|
||||||
|
Tests\Helpers\ApiClient::resetCookies();
|
||||||
|
$productA = null;
|
||||||
|
$productB = null;
|
||||||
|
$promoId = null;
|
||||||
|
$promoCode = 'E2EBun' . strtoupper(uniqid());
|
||||||
|
|
||||||
|
try {
|
||||||
|
$productA = bundlePromoCreateProduct(100.0);
|
||||||
|
$productB = bundlePromoCreateProduct(50.0);
|
||||||
|
// 10% off product B, but only when A and B are both in the cart.
|
||||||
|
$promoId = bundlePromoCreatePromo($promoCode, 'percentage', 10, [
|
||||||
|
'active' => 1,
|
||||||
|
'recurring' => 1,
|
||||||
|
'products' => [$productB],
|
||||||
|
'requires_products' => [$productA, $productB],
|
||||||
|
]);
|
||||||
|
|
||||||
|
['token' => $clientToken, 'password' => $password, 'email' => $email]
|
||||||
|
= bundlePromoCreateClient();
|
||||||
|
|
||||||
|
Tests\Helpers\ApiClient::resetCookies();
|
||||||
|
$login = Tests\Helpers\ApiClient::request('guest/client/login', ['email' => $email, 'password' => $password]);
|
||||||
|
assertApiSuccess($login);
|
||||||
|
// Guest add_item resets the cart unless multiple=1 is passed.
|
||||||
|
Tests\Helpers\ApiClient::request('guest/cart/add_item', ['id' => $productB, 'multiple' => 1]);
|
||||||
|
|
||||||
|
// Only B is present: applying names the missing product.
|
||||||
|
$rejected = Tests\Helpers\ApiClient::request('guest/cart/apply_promo', ['promocode' => $promoCode]);
|
||||||
|
expect($rejected->wasSuccessful())->toBeFalse();
|
||||||
|
expect($rejected->getErrorMessage())->toContain('requires the following products');
|
||||||
|
|
||||||
|
$cart = bundlePromoGetCart();
|
||||||
|
expect((float) $cart['discount'])->toEqual(0.0);
|
||||||
|
|
||||||
|
// Completing the bundle lets the code apply with the scoped discount.
|
||||||
|
Tests\Helpers\ApiClient::request('guest/cart/add_item', ['id' => $productA, 'multiple' => 1]);
|
||||||
|
$applied = Tests\Helpers\ApiClient::request('guest/cart/apply_promo', ['promocode' => $promoCode]);
|
||||||
|
assertApiSuccess($applied);
|
||||||
|
|
||||||
|
$cart = bundlePromoGetCart();
|
||||||
|
expect((float) $cart['discount'])->toEqual(5.0);
|
||||||
|
|
||||||
|
$checkout = Tests\Helpers\ApiClient::request('client/cart/checkout', [
|
||||||
|
'gateway_id' => bundlePromoCustomGatewayId(),
|
||||||
|
], 'client', $clientToken);
|
||||||
|
assertApiSuccess($checkout);
|
||||||
|
$orderIds = $checkout->getResult()['orders'];
|
||||||
|
expect($orderIds)->toHaveCount(2);
|
||||||
|
|
||||||
|
$discounted = 0;
|
||||||
|
foreach ($orderIds as $orderId) {
|
||||||
|
$order = bundlePromoGetOrder((int) $orderId);
|
||||||
|
$discounted += (float) $order['discount'];
|
||||||
|
}
|
||||||
|
expect($discounted)->toEqual(5.0);
|
||||||
|
} finally {
|
||||||
|
bundlePromoCleanupClient();
|
||||||
|
bundlePromoDeactivatePromo($promoId);
|
||||||
|
bundlePromoDeleteProduct($productA);
|
||||||
|
bundlePromoDeleteProduct($productB);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bundle condition gates automatic promos', function (): void {
|
||||||
|
Tests\Helpers\ApiClient::resetCookies();
|
||||||
|
$productA = null;
|
||||||
|
$productB = null;
|
||||||
|
$promoId = null;
|
||||||
|
$promoCode = 'E2EBun' . strtoupper(uniqid());
|
||||||
|
|
||||||
|
try {
|
||||||
|
$productA = bundlePromoCreateProduct(100.0);
|
||||||
|
$productB = bundlePromoCreateProduct(50.0);
|
||||||
|
$promoId = bundlePromoCreatePromo($promoCode, 'percentage', 10, [
|
||||||
|
'active' => 1,
|
||||||
|
'recurring' => 1,
|
||||||
|
'auto_apply' => 1,
|
||||||
|
'products' => [$productB],
|
||||||
|
'requires_products' => [$productA, $productB],
|
||||||
|
]);
|
||||||
|
|
||||||
|
['token' => $clientToken, 'password' => $password, 'email' => $email]
|
||||||
|
= bundlePromoCreateClient();
|
||||||
|
|
||||||
|
Tests\Helpers\ApiClient::resetCookies();
|
||||||
|
$login = Tests\Helpers\ApiClient::request('guest/client/login', ['email' => $email, 'password' => $password]);
|
||||||
|
assertApiSuccess($login);
|
||||||
|
// Guest add_item resets the cart unless multiple=1 is passed.
|
||||||
|
Tests\Helpers\ApiClient::request('guest/cart/add_item', ['id' => $productB, 'multiple' => 1]);
|
||||||
|
|
||||||
|
// Incomplete bundle: no automatic promo resolves.
|
||||||
|
$cart = bundlePromoGetCart();
|
||||||
|
expect((float) $cart['discount'])->toEqual(0.0);
|
||||||
|
expect($cart['auto_promos'] ?? [])->toBe([]);
|
||||||
|
|
||||||
|
Tests\Helpers\ApiClient::request('guest/cart/add_item', ['id' => $productA, 'multiple' => 1]);
|
||||||
|
|
||||||
|
$cart = bundlePromoGetCart();
|
||||||
|
expect((float) $cart['discount'])->toEqual(5.0);
|
||||||
|
expect($cart['promo_source'] ?? null)->toBe('auto');
|
||||||
|
expect($cart['auto_promos'])->toHaveCount(1);
|
||||||
|
expect($cart['auto_promos'][0]['code'])->toBe($promoCode);
|
||||||
|
|
||||||
|
$checkout = Tests\Helpers\ApiClient::request('client/cart/checkout', [
|
||||||
|
'gateway_id' => bundlePromoCustomGatewayId(),
|
||||||
|
], 'client', $clientToken);
|
||||||
|
assertApiSuccess($checkout);
|
||||||
|
expect($checkout->getResult()['orders'])->toHaveCount(2);
|
||||||
|
} finally {
|
||||||
|
bundlePromoCleanupClient();
|
||||||
|
bundlePromoDeactivatePromo($promoId);
|
||||||
|
bundlePromoDeleteProduct($productA);
|
||||||
|
bundlePromoDeleteProduct($productB);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('staff basket enforces the bundle condition on apply and checkout', function (): void {
|
||||||
|
$clientId = bundlePromoCreateStaffClient();
|
||||||
|
$productA = bundlePromoCreateProduct(100.0);
|
||||||
|
$productB = bundlePromoCreateProduct(50.0);
|
||||||
|
$promoCode = 'E2EBun' . strtoupper(uniqid());
|
||||||
|
$promoId = bundlePromoCreatePromo($promoCode, 'percentage', 10, [
|
||||||
|
'active' => 1,
|
||||||
|
'recurring' => 1,
|
||||||
|
'products' => [$productB],
|
||||||
|
'requires_products' => [$productA, $productB],
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$added = Tests\Helpers\ApiClient::request('admin/cart/staff_basket_add_item', [
|
||||||
|
'client_id' => $clientId,
|
||||||
|
'id' => $productB,
|
||||||
|
]);
|
||||||
|
assertApiSuccess($added);
|
||||||
|
|
||||||
|
$rejected = Tests\Helpers\ApiClient::request('admin/cart/staff_basket_apply_promo', [
|
||||||
|
'client_id' => $clientId,
|
||||||
|
'promocode' => $promoCode,
|
||||||
|
]);
|
||||||
|
expect($rejected->wasSuccessful())->toBeFalse();
|
||||||
|
expect($rejected->getErrorMessage())->toContain('requires the following products');
|
||||||
|
|
||||||
|
$added = Tests\Helpers\ApiClient::request('admin/cart/staff_basket_add_item', [
|
||||||
|
'client_id' => $clientId,
|
||||||
|
'id' => $productA,
|
||||||
|
]);
|
||||||
|
assertApiSuccess($added);
|
||||||
|
|
||||||
|
$applied = Tests\Helpers\ApiClient::request('admin/cart/staff_basket_apply_promo', [
|
||||||
|
'client_id' => $clientId,
|
||||||
|
'promocode' => $promoCode,
|
||||||
|
]);
|
||||||
|
assertApiSuccess($applied);
|
||||||
|
|
||||||
|
$basket = Tests\Helpers\ApiClient::request('admin/cart/staff_basket_get', ['client_id' => $clientId]);
|
||||||
|
assertApiSuccess($basket);
|
||||||
|
expect((float) $basket->getResult()['discount'])->toEqual(5.0);
|
||||||
|
|
||||||
|
$checkout = Tests\Helpers\ApiClient::request('admin/cart/staff_basket_checkout', ['client_id' => $clientId]);
|
||||||
|
assertApiSuccess($checkout);
|
||||||
|
$result = $checkout->getResult();
|
||||||
|
expect($result['orders'])->toHaveCount(2);
|
||||||
|
|
||||||
|
$invoice = bundlePromoGetInvoice((int) $result['invoice_id']);
|
||||||
|
$discount = 0.0;
|
||||||
|
foreach ($invoice['lines'] ?? [] as $line) {
|
||||||
|
if (($line['unit'] ?? null) === 'discount') {
|
||||||
|
$discount += (float) ($line['price'] ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect($discount)->toEqual(-5.0);
|
||||||
|
} finally {
|
||||||
|
// Tear down in dependency order: the checked-out orders belong to
|
||||||
|
// the client, so discard any leftover basket and delete the client
|
||||||
|
// before deactivating the promo and deleting the products.
|
||||||
|
Tests\Helpers\ApiClient::request('admin/cart/staff_basket_discard', ['client_id' => $clientId]);
|
||||||
|
bundlePromoCleanupClient($clientId);
|
||||||
|
bundlePromoDeactivatePromo($promoId);
|
||||||
|
bundlePromoDeleteProduct($productA);
|
||||||
|
bundlePromoDeleteProduct($productB);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function bundlePromoCreateProduct(float $price): int
|
||||||
|
{
|
||||||
|
$result = Tests\Helpers\ApiClient::request('admin/product/prepare', [
|
||||||
|
'title' => 'E2E Bundle Product ' . uniqid(),
|
||||||
|
'type' => 'custom',
|
||||||
|
'product_category_id' => 1,
|
||||||
|
]);
|
||||||
|
assertApiSuccess($result);
|
||||||
|
assertApiResultIsInt($result);
|
||||||
|
$productId = (int) $result->getResult();
|
||||||
|
|
||||||
|
$update = Tests\Helpers\ApiClient::request('admin/product/update', [
|
||||||
|
'id' => $productId,
|
||||||
|
'status' => 'enabled',
|
||||||
|
'pricing' => ['type' => 'once', 'once' => ['price' => $price, 'setup' => 0]],
|
||||||
|
]);
|
||||||
|
assertApiSuccess($update);
|
||||||
|
|
||||||
|
return $productId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bundlePromoCreatePromo(string $code, string $type, float $value, array $flags): int
|
||||||
|
{
|
||||||
|
$result = Tests\Helpers\ApiClient::request('admin/product/promo_create', [
|
||||||
|
'code' => $code,
|
||||||
|
'type' => $type,
|
||||||
|
'value' => $value,
|
||||||
|
...$flags,
|
||||||
|
]);
|
||||||
|
assertApiSuccess($result);
|
||||||
|
assertApiResultIsInt($result);
|
||||||
|
|
||||||
|
return (int) $result->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bundlePromoCreateClient(): array
|
||||||
|
{
|
||||||
|
$email = 'client_' . uniqid() . '@example.com';
|
||||||
|
$password = 'A1a' . bin2hex(random_bytes(6));
|
||||||
|
$created = Tests\Helpers\ApiClient::request('admin/client/create', [
|
||||||
|
'email' => $email,
|
||||||
|
'first_name' => 'Test',
|
||||||
|
'password' => $password,
|
||||||
|
'send_welcome_email' => 0,
|
||||||
|
]);
|
||||||
|
assertApiSuccess($created);
|
||||||
|
$GLOBALS['bundlePromoClientId'] = (int) $created->getResult();
|
||||||
|
|
||||||
|
Tests\Helpers\ApiClient::resetCookies();
|
||||||
|
$token = Tests\Helpers\ApiClient::request('admin/profile/api_key_reset', [
|
||||||
|
'id' => $GLOBALS['bundlePromoClientId'],
|
||||||
|
]);
|
||||||
|
assertApiSuccess($token);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $GLOBALS['bundlePromoClientId'],
|
||||||
|
'token' => (string) $token->getResult(),
|
||||||
|
'password' => $password,
|
||||||
|
'email' => $email,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function bundlePromoCreateStaffClient(): int
|
||||||
|
{
|
||||||
|
$created = Tests\Helpers\ApiClient::request('admin/client/create', [
|
||||||
|
'email' => 'basket_' . uniqid() . '@example.com',
|
||||||
|
'first_name' => 'Test',
|
||||||
|
'password' => 'A1a' . bin2hex(random_bytes(6)),
|
||||||
|
'send_welcome_email' => 0,
|
||||||
|
]);
|
||||||
|
assertApiSuccess($created);
|
||||||
|
assertApiResultIsInt($created);
|
||||||
|
|
||||||
|
return (int) $created->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bundlePromoGetCart(): array
|
||||||
|
{
|
||||||
|
$result = Tests\Helpers\ApiClient::request('guest/cart/get');
|
||||||
|
assertApiSuccess($result);
|
||||||
|
assertApiResultIsArray($result);
|
||||||
|
|
||||||
|
return $result->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bundlePromoGetOrder(int $orderId): array
|
||||||
|
{
|
||||||
|
$result = Tests\Helpers\ApiClient::request('admin/order/get', ['id' => $orderId]);
|
||||||
|
assertApiSuccess($result);
|
||||||
|
assertApiResultIsArray($result);
|
||||||
|
|
||||||
|
return $result->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bundlePromoGetInvoice(int $invoiceId): array
|
||||||
|
{
|
||||||
|
$result = Tests\Helpers\ApiClient::request('admin/invoice/get', ['id' => $invoiceId]);
|
||||||
|
assertApiSuccess($result);
|
||||||
|
assertApiResultIsArray($result);
|
||||||
|
|
||||||
|
return $result->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bundlePromoCustomGatewayId(): int
|
||||||
|
{
|
||||||
|
$result = Tests\Helpers\ApiClient::request('admin/invoice/gateway_get_pairs');
|
||||||
|
assertApiSuccess($result);
|
||||||
|
foreach ($result->getResult() as $id => $title) {
|
||||||
|
if ($title === 'Custom') {
|
||||||
|
return (int) $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException('Custom payment gateway not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
function bundlePromoCleanupClient(?int $clientId = null): void
|
||||||
|
{
|
||||||
|
$clientId ??= $GLOBALS['bundlePromoClientId'] ?? null;
|
||||||
|
if ($clientId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$deleted = Tests\Helpers\ApiClient::request('admin/client/delete', ['id' => $clientId]);
|
||||||
|
assertApiSuccess($deleted);
|
||||||
|
unset($GLOBALS['bundlePromoClientId']);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bundlePromoDeactivatePromo(?int $promoId): void
|
||||||
|
{
|
||||||
|
if ($promoId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promos with redemption history cannot be deleted; disable instead so
|
||||||
|
// later runs and other suites never match a stale automatic promo.
|
||||||
|
$result = Tests\Helpers\ApiClient::request('admin/product/promo_update', [
|
||||||
|
'id' => $promoId,
|
||||||
|
'active' => 0,
|
||||||
|
'auto_apply' => 0,
|
||||||
|
]);
|
||||||
|
assertApiSuccess($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bundlePromoDeleteProduct(?int $productId): void
|
||||||
|
{
|
||||||
|
if ($productId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = Tests\Helpers\ApiClient::request('admin/product/delete', ['id' => $productId]);
|
||||||
|
assertApiSuccess($result);
|
||||||
|
}
|
||||||
@@ -1361,7 +1361,7 @@ test('create promo', function (): void {
|
|||||||
$di['em'] = $emMock;
|
$di['em'] = $emMock;
|
||||||
|
|
||||||
$service->setDi($di);
|
$service->setDi($di);
|
||||||
$result = $service->createPromo('code', 'percentage', 50, [], [], [], []);
|
$result = $service->createPromo('code', 'percentage', 50, [], [], [], [], []);
|
||||||
expect($result)->toBeInt();
|
expect($result)->toBeInt();
|
||||||
expect($result)->toEqual(1);
|
expect($result)->toEqual(1);
|
||||||
});
|
});
|
||||||
@@ -1383,6 +1383,7 @@ test('duplicate promo', function (): void {
|
|||||||
->setProducts('[1]')
|
->setProducts('[1]')
|
||||||
->setPeriods('["1M"]')
|
->setPeriods('["1M"]')
|
||||||
->setClientGroups('[2]')
|
->setClientGroups('[2]')
|
||||||
|
->setRequiresProducts('[5]')
|
||||||
->setStartAt(new DateTime('2012-01-01'))
|
->setStartAt(new DateTime('2012-01-01'))
|
||||||
->setEndAt(new DateTime('2012-01-02'));
|
->setEndAt(new DateTime('2012-01-02'));
|
||||||
|
|
||||||
@@ -1434,6 +1435,7 @@ test('duplicate promo', function (): void {
|
|||||||
expect($emMock->captured->getProducts())->toBe('[1]');
|
expect($emMock->captured->getProducts())->toBe('[1]');
|
||||||
expect($emMock->captured->getPeriods())->toBe('["1M"]');
|
expect($emMock->captured->getPeriods())->toBe('["1M"]');
|
||||||
expect($emMock->captured->getClientGroups())->toBe('[2]');
|
expect($emMock->captured->getClientGroups())->toBe('[2]');
|
||||||
|
expect($emMock->captured->getRequiresProducts())->toBe('[5]');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('duplicate promo generates alternate code when copy code already exists', function (): void {
|
test('duplicate promo generates alternate code when copy code already exists', function (): void {
|
||||||
@@ -3223,6 +3225,54 @@ test('findEligibleAutoPromos skips promos with no applicable lines', function ()
|
|||||||
expect($serviceMock->findEligibleAutoPromos($client, [['product' => $product, 'config' => []]]))->toBe([]);
|
expect($serviceMock->findEligibleAutoPromos($client, [['product' => $product, 'config' => []]]))->toBe([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getPromoRequiredProducts decodes and normalizes the bundle condition', function (): void {
|
||||||
|
$service = new Service();
|
||||||
|
|
||||||
|
expect($service->getPromoRequiredProducts(productTestCreatePromoEntity(1)))->toBe([]);
|
||||||
|
expect($service->getPromoRequiredProducts(productTestCreatePromoEntity(2)->setRequiresProducts('[5,"7",0,-2,5]')))->toBe([5, 7]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('findMissingRequiredProductIds reports only the absent products', function (): void {
|
||||||
|
$service = new Service();
|
||||||
|
$promo = productTestCreatePromoEntity(7)->setRequiresProducts('[5,9]');
|
||||||
|
|
||||||
|
expect($service->findMissingRequiredProductIds($promo, [5, 9, 12]))->toBe([]);
|
||||||
|
expect($service->findMissingRequiredProductIds($promo, [5]))->toBe([9]);
|
||||||
|
expect($service->findMissingRequiredProductIds(productTestCreatePromoEntity(8), [5]))->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isPromoCartConditionMet checks line products against the bundle condition', function (): void {
|
||||||
|
$service = new Service();
|
||||||
|
$promo = productTestCreatePromoEntity(7)->setRequiresProducts('[5,9]');
|
||||||
|
|
||||||
|
$lines = [
|
||||||
|
['product' => productTestCreateProductEntity(5), 'config' => []],
|
||||||
|
['product' => productTestCreateProductEntity(9), 'config' => []],
|
||||||
|
];
|
||||||
|
|
||||||
|
expect($service->isPromoCartConditionMet($promo, $lines))->toBeTrue();
|
||||||
|
expect($service->isPromoCartConditionMet($promo, [$lines[0]]))->toBeFalse();
|
||||||
|
expect($service->isPromoCartConditionMet(productTestCreatePromoEntity(8), []))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('findEligibleAutoPromos skips promos whose bundle condition is unmet', function (): void {
|
||||||
|
$client = createEntity(Client::class, ['id' => 9]);
|
||||||
|
$product = productTestCreateProductEntity(5);
|
||||||
|
$promo = productTestCreatePromoEntity(7)->setAutoApply(true)->setRequiresProducts('[5,9]');
|
||||||
|
|
||||||
|
$promoRepo = Mockery::mock(PromoRepository::class);
|
||||||
|
$promoRepo->shouldReceive('findAutoApplyPromos')->once()->andReturn([$promo]);
|
||||||
|
|
||||||
|
$serviceMock = Mockery::mock(Service::class)->makePartial();
|
||||||
|
$serviceMock->shouldReceive('getPromoRepository')->once()->andReturn($promoRepo);
|
||||||
|
$serviceMock->shouldReceive('promoCanBeApplied')->once()->andReturn(true);
|
||||||
|
$serviceMock->shouldReceive('isPromoAvailableForClientGroup')->once()->andReturn(true);
|
||||||
|
$serviceMock->shouldReceive('canClientUsePromo')->once()->andReturn(true);
|
||||||
|
$serviceMock->shouldNotReceive('getProductDiscount');
|
||||||
|
|
||||||
|
expect($serviceMock->findEligibleAutoPromos($client, [['product' => $product, 'config' => []]]))->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('resolvePromoReference prefers code over id and returns null when empty', function (): void {
|
test('resolvePromoReference prefers code over id and returns null when empty', function (): void {
|
||||||
$serviceMock = Mockery::mock(Service::class)->makePartial();
|
$serviceMock = Mockery::mock(Service::class)->makePartial();
|
||||||
$promo = productTestCreatePromoEntity(7);
|
$promo = productTestCreatePromoEntity(7);
|
||||||
|
|||||||
Reference in new issue
Block a user