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:
Adam Daley authored and GitHub committed 2026-09-24 00:59:54 +01:00
1 parent 7980877e63
commit 77fffad293
9 files changed
+651 -15

No files matched your search

+58 -2
View File
@@ -541,6 +541,8 @@ class Service implements InjectionAwareInterface
throw new \FOSSBilling\InformationException('Add products to your cart before applying promo code');
}
$this->assertPromoCartConditionMet($cart, $promo);
$cart->setPromoId($promoId);
$this->persistCart($cart);
@@ -549,6 +551,44 @@ class Service implements InjectionAwareInterface
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
{
$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
* 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>}
*/
@@ -655,9 +698,12 @@ class Service implements InjectionAwareInterface
{
$promoId = $cart->getPromoId();
if ($promoId) {
$promo = $this->getProductService()->findPromoById((int) $promoId);
$conditionMet = $this->getProductService()->findMissingRequiredProductIds($promo, $this->getCartProductIds($cart, $cartProducts)) === [];
return [
'source' => 'manual',
'promos' => [$this->getProductService()->findPromoById((int) $promoId)],
'promos' => $conditionMet ? [$promo] : [],
];
}
@@ -837,6 +883,8 @@ class Service implements InjectionAwareInterface
if (!$this->isPromoAvailableForClientGroup($promo)) {
throw new \FOSSBilling\InformationException('Promo code cannot be applied to your account');
}
$this->assertPromoCartConditionMet($cart, $promo);
}
$this->di['events_manager']->fire(
@@ -919,6 +967,8 @@ class Service implements InjectionAwareInterface
if (!$this->isPromoAvailableForClientGroup($promo, $client)) {
throw new \FOSSBilling\InformationException('Promo code cannot be applied to this client account');
}
$this->assertPromoCartConditionMet($basket, $promo);
}
$this->di['events_manager']->fire(
@@ -1035,6 +1085,12 @@ class Service implements InjectionAwareInterface
$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 === []) {
// A manual code suppresses automatic promos; resolve here
// so totals, reservations, and redemptions share one set.
+70 -3
View File
@@ -29,6 +29,7 @@ use Symfony\Component\HttpFoundation\Request;
use function Tests\Helpers\container;
use function Tests\Helpers\createEntity;
use function Tests\Helpers\moduleService;
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);
$productService = Mockery::mock(ProductService::class);
$productService->shouldReceive('findMissingRequiredProductIds')->once()->with($promo, [0, 0])->andReturn([]);
$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);
@@ -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);
});
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 {
$cart = new Cart();
$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('rm')->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->shouldReceive('fire')->atLeast()->once();
@@ -690,6 +728,7 @@ test('checkoutCart returns array with expected keys', function (): void {
$productService = Mockery::mock(ProductService::class);
$productService->shouldReceive('findPromoById')->once()->with(1)->andReturn($promo);
$productService->shouldReceive('findMissingRequiredProductIds')->once()->with($promo, [])->andReturn([]);
$di = container();
$di['events_manager'] = $eventMock;
@@ -827,6 +866,7 @@ test('createFromCart with promo entity uses product promo service', function ():
$productService = Mockery::mock(ProductService::class);
$productService->shouldReceive('findPromoById')->once()->with(7)->andReturn($promo);
$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('reservePromosForOrder')->once()->with([$promo], Mockery::type(Order::class));
$productService->shouldReceive('createCheckoutPromoRedemptionsForPromos')->once()->with(
@@ -1378,6 +1418,7 @@ test('createFromCart compensates promo usage on transaction failure', function (
$productService = Mockery::mock(ProductService::class);
$productService->shouldReceive('findPromoById')->once()->with(7)->andReturn($promo);
$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('reservePromosForOrder')->once()->with([$promo], Mockery::type(Order::class));
$productService->shouldReceive('createCheckoutPromoRedemptionsForPromos')
@@ -1481,6 +1522,7 @@ test('createFromCart releases reserved stock on transaction failure', function (
$productService = Mockery::mock(ProductService::class);
$productService->shouldReceive('findPromoById')->once()->with(7)->andReturn($promo);
$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('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->shouldReceive('findPromoById')->once()->with(7)->andReturn($promo);
$productService->shouldReceive('findMissingRequiredProductIds')->once()->with($promo, [])->andReturn([]);
$di = container();
$di['mod_service'] = $di->protect(fn () => $productService);
$service = new Service();
$service->setDi($di);
$serviceMock = Mockery::mock(Service::class)->makePartial();
$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['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 {
$cart = createEntity(Cart::class);
+8 -1
View File
@@ -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 $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 $freesetup - flag to enable/disable free setup price
* @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'])) {
$clientGroups = $data['client_groups'];
}
$requiresProducts = [];
if (isset($data['requires_products']) && is_array($data['requires_products'])) {
$requiresProducts = $data['requires_products'];
}
$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 array $products - list of product ids for which this promo code applies
* @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 $freesetup - flag to enable/disable free setup price
* @optional bool $once_per_client - flag to enable/disable promo code usage once per client
+16
View File
@@ -82,6 +82,9 @@ class Promo implements ApiArrayInterface, TimestampInterface
#[ORM\Column(name: 'client_groups', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: true)]
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)]
private ?\DateTime $startAt = null;
@@ -117,6 +120,7 @@ class Promo implements ApiArrayInterface, TimestampInterface
'products' => $this->products,
'periods' => $this->periods,
'client_groups' => $this->clientGroups,
'requires_products' => $this->requiresProducts,
'start_at' => $this->startAt?->format('Y-m-d H:i:s'),
'end_at' => $this->endAt?->format('Y-m-d H:i:s'),
'auto_apply' => $this->autoApply ?? false,
@@ -288,6 +292,18 @@ class Promo implements ApiArrayInterface, TimestampInterface
return $this;
}
public function getRequiresProducts(): ?string
{
return $this->requiresProducts;
}
public function setRequiresProducts(?string $requiresProducts): self
{
$this->requiresProducts = $requiresProducts;
return $this;
}
public function getStartAt(): ?\DateTime
{
return $this->startAt;
+60 -1
View File
@@ -1252,7 +1252,7 @@ class Service implements InjectionAwareInterface
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) {
throw new \FOSSBilling\InformationException('This promotion code already exists.');
@@ -1267,6 +1267,7 @@ class Service implements InjectionAwareInterface
'products' => $products,
'periods' => $periods,
'client_groups' => $clientGroups,
'requires_products' => $requiresProducts,
]);
$this->di['em']->persist($promo);
@@ -1298,6 +1299,7 @@ class Service implements InjectionAwareInterface
->setProducts($model->getProducts())
->setPeriods($model->getPeriods())
->setClientGroups($model->getClientGroups())
->setRequiresProducts($model->getRequiresProducts())
->setStartAt($model->getStartAt() !== null ? clone $model->getStartAt() : null)
->setEndAt($model->getEndAt() !== null ? clone $model->getEndAt() : null);
@@ -1639,6 +1641,10 @@ class Service implements InjectionAwareInterface
continue;
}
if (!$this->isPromoCartConditionMet($promo, $lines)) {
continue;
}
$discount = 0.0;
foreach ($lines as $line) {
$product = $line['product'];
@@ -2027,13 +2033,16 @@ class Service implements InjectionAwareInterface
public function enrichPromoApiArray(array $result, $deep = false, $identity = null): array
{
$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;
$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']);
$result['applies_to'] = $products;
$result['requires'] = $requiredProducts;
$result['cgroups'] = $clientGroups;
$result['products'] = $this->decodePromoSelection($result['products'] ?? null);
$result['requires_products'] = $this->decodePromoSelection($result['requires_products'] ?? null);
$result['periods'] = $this->decodePromoSelection($result['periods'] ?? null);
$result['client_groups'] = $this->decodePromoSelection($result['client_groups'] ?? null);
$result['redemption_count'] = $redemptionCount;
@@ -2567,6 +2576,55 @@ class Service implements InjectionAwareInterface
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)
{
if (!$this->isPromoApplicableToProduct($promo, $product, $config)) {
@@ -3025,6 +3083,7 @@ class Service implements InjectionAwareInterface
->setProducts($this->encodePromoSelection($data['products'] ?? $this->decodePromoSelection($promo->getProducts())))
->setPeriods($this->encodePromoSelection($data['periods'] ?? $this->decodePromoSelection($promo->getPeriods())))
->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()))
->setEndAt($this->normalizePromoDateTimeObject($data['end_at'] ?? $promo->getEndAt()));
}
@@ -320,27 +320,35 @@
<hr class="my-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>
<input type="hidden" name="products[]" value=""/>
<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>
</table>
</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>
<input type="hidden" name="periods[]" value=""/>
<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>
</table>
</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>
<input type="hidden" name="client_groups[]" value=""/>
<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>
</table>
</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>
<input type="hidden" name="id" value="{{ promo.id }}">
{% endblock %}
@@ -331,10 +331,10 @@
<div class="fb-modal-section-heading-row">
<h4 class="fb-modal-section-title">{{ 'Applicability'|trans }}</h4>
</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 class="row g-3">
<div class="col-xl-4">
<div class="col-xl-3">
<label class="form-label">{{ 'Applicable Products'|trans }}</label>
<input type="hidden" name="products[]" value="">
<div class="table-responsive border rounded-3" style="max-height: 16rem;">
@@ -348,7 +348,7 @@
</table>
</div>
</div>
<div class="col-xl-4">
<div class="col-xl-3">
<label class="form-label">{{ 'Applicable Periods'|trans }}</label>
<input type="hidden" name="periods[]" value="">
<div class="table-responsive border rounded-3" style="max-height: 16rem;">
@@ -362,7 +362,7 @@
</table>
</div>
</div>
<div class="col-xl-4">
<div class="col-xl-3">
<label class="form-label">{{ 'Applicable Client Groups'|trans }}</label>
<input type="hidden" name="client_groups[]" value="">
<div class="table-responsive border rounded-3" style="max-height: 16rem;">
@@ -376,6 +376,21 @@
</table>
</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>
</section>
</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);
}
+51 -1
View File
@@ -1361,7 +1361,7 @@ test('create promo', function (): void {
$di['em'] = $emMock;
$service->setDi($di);
$result = $service->createPromo('code', 'percentage', 50, [], [], [], []);
$result = $service->createPromo('code', 'percentage', 50, [], [], [], [], []);
expect($result)->toBeInt();
expect($result)->toEqual(1);
});
@@ -1383,6 +1383,7 @@ test('duplicate promo', function (): void {
->setProducts('[1]')
->setPeriods('["1M"]')
->setClientGroups('[2]')
->setRequiresProducts('[5]')
->setStartAt(new DateTime('2012-01-01'))
->setEndAt(new DateTime('2012-01-02'));
@@ -1434,6 +1435,7 @@ test('duplicate promo', function (): void {
expect($emMock->captured->getProducts())->toBe('[1]');
expect($emMock->captured->getPeriods())->toBe('["1M"]');
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 {
@@ -3223,6 +3225,54 @@ test('findEligibleAutoPromos skips promos with no applicable lines', function ()
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 {
$serviceMock = Mockery::mock(Service::class)->makePartial();
$promo = productTestCreatePromoEntity(7);