From 01189993f20acd66137b919605bb3dadf681b6c8 Mon Sep 17 00:00:00 2001 From: Joel Male Date: Sat, 13 Jun 2026 14:27:42 +1000 Subject: [PATCH] feat: add applies_to support for quantity-limited item conditions --- README.md | 36 +++++ src/CartCondition.php | 8 + src/ItemCollection.php | 27 +++- tests/Unit/QuantityLimitedConditionTest.php | 159 ++++++++++++++++++++ 4 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/QuantityLimitedConditionTest.php diff --git a/README.md b/README.md index fe45072..3d5bd76 100644 --- a/README.md +++ b/README.md @@ -607,6 +607,42 @@ #### Conditions on items Cart::add($item); ``` +##### Limiting a condition to a specific quantity of items + +By default, an item condition applies to every unit of the item. Use `applies_to` to limit it to the first N units: + +```php +// 5% off the first item only - for 2x $50 items: (50*0.95) + 50 = $97.50 +$firstItemDiscount = new CartCondition([ + 'name' => '5% Off First Item', + 'type' => 'discount', + 'value' => '-5%', + 'applies_to' => 1, +]); + +Cart::add([ + 'id' => 1, + 'name' => 'Widget', + 'price' => 50.00, + 'quantity' => 2, + 'attributes' => [], + 'conditions' => $firstItemDiscount, +]); + +Cart::getSubTotal(); // 97.50 +``` + +When combining conditions, those without `applies_to` apply to every unit while those with `applies_to` only apply to the first N: + +```php +$limitedDiscount = new CartCondition(['name' => '10% Off First', 'type' => 'discount', 'value' => '-10%', 'applies_to' => 1]); +$globalDiscount = new CartCondition(['name' => '5% Off All', 'type' => 'discount', 'value' => '-5%']); + +// Item 1: 100 * 0.90 * 0.95 = 85.50 +// Item 2: 100 * 0.95 = 95.00 +// Subtotal: 180.50 +``` + > NOTE: All cart per-item conditions should be added before calling **Cart::getSubTotal()** Then Finally you can call **Cart::getSubTotal()** to get the Cart sub total with the applied conditions on each of the items. diff --git a/src/CartCondition.php b/src/CartCondition.php index bb671ee..1612baa 100644 --- a/src/CartCondition.php +++ b/src/CartCondition.php @@ -78,6 +78,14 @@ public function getMaximum(): int|float|null return $this->args['maximum'] ?? null; } + /** + * Get the number of items this condition applies to (null = all items) + */ + public function getAppliesToQuantity(): int|null + { + return isset($this->args['applies_to']) ? (int) $this->args['applies_to'] : null; + } + /** * get the additional attributes of a condition * diff --git a/src/ItemCollection.php b/src/ItemCollection.php index 033d394..1f5d563 100644 --- a/src/ItemCollection.php +++ b/src/ItemCollection.php @@ -170,6 +170,31 @@ public function getPriceWithConditions($formatted = true) */ public function getPriceSumWithConditions($formatted = true) { - return Helpers::formatValue($this->getPriceWithConditions(false) * $this->quantity, $formatted, $this->config); + $conditions = $this->hasConditions() ? $this->getConditions() : []; + $conditions = is_array($conditions) ? $conditions : [$conditions]; + + $hasLimit = collect($conditions)->some(fn ($c) => $c->getAppliesToQuantity() !== null); + + if (! $hasLimit) { + return Helpers::formatValue($this->getPriceWithConditions(false) * $this->quantity, $formatted, $this->config); + } + + $total = 0.0; + + for ($i = 1; $i <= $this->quantity; $i++) { + $price = $this->price; + + foreach ($conditions as $condition) { + $limit = $condition->getAppliesToQuantity(); + + if ($limit === null || $i <= $limit) { + $price = $condition->applyCondition($price); + } + } + + $total += $price; + } + + return Helpers::formatValue($total, $formatted, $this->config); } } diff --git a/tests/Unit/QuantityLimitedConditionTest.php b/tests/Unit/QuantityLimitedConditionTest.php new file mode 100644 index 0000000..6a0a14a --- /dev/null +++ b/tests/Unit/QuantityLimitedConditionTest.php @@ -0,0 +1,159 @@ +shouldReceive('dispatch'); + + $this->cart = new Cart( + new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'), + $events, + 'cart', + require (__DIR__ . '/../Helpers/ConfigConditionsMock.php') + ); +}); + +afterEach(function () { + Mockery::close(); +}); + +describe('quantity-limited item conditions', function () { + test('condition without applies_to applies to all items', function () { + $discount = new CartCondition([ + 'name' => '5% Off', + 'type' => 'discount', + 'value' => '-5%', + ]); + + $this->cart->add([ + 'id' => 1, + 'name' => 'Widget', + 'price' => 50.00, + 'quantity' => 2, + 'attributes' => [], + 'conditions' => $discount, + ]); + + // Without applies_to: 50*0.95 * 2 = 95 + expect($this->cart->get(1)->getPriceSumWithConditions())->toEqual(95.00); + expect($this->cart->getSubTotal())->toEqual(95.00); + }); + + test('condition with applies_to only discounts that many items', function () { + $discount = new CartCondition([ + 'name' => '5% Off First Item', + 'type' => 'discount', + 'value' => '-5%', + 'applies_to' => 1, + ]); + + $this->cart->add([ + 'id' => 1, + 'name' => 'Widget', + 'price' => 50.00, + 'quantity' => 2, + 'attributes' => [], + 'conditions' => $discount, + ]); + + // applies_to: 1 → (50*0.95) + 50 = 97.5 + expect($this->cart->get(1)->getPriceSumWithConditions())->toEqual(97.50); + expect($this->cart->getSubTotal())->toEqual(97.50); + }); + + test('applies_to greater than or equal to quantity discounts all items', function () { + $discount = new CartCondition([ + 'name' => '5% Off', + 'type' => 'discount', + 'value' => '-5%', + 'applies_to' => 5, + ]); + + $this->cart->add([ + 'id' => 1, + 'name' => 'Widget', + 'price' => 50.00, + 'quantity' => 2, + 'attributes' => [], + 'conditions' => $discount, + ]); + + // applies_to 5 >= qty 2, so all items discounted: 50*0.95 * 2 = 95 + expect($this->cart->get(1)->getPriceSumWithConditions())->toEqual(95.00); + }); + + test('applies_to of zero means condition does not apply to any item', function () { + $discount = new CartCondition([ + 'name' => '5% Off', + 'type' => 'discount', + 'value' => '-5%', + 'applies_to' => 0, + ]); + + $this->cart->add([ + 'id' => 1, + 'name' => 'Widget', + 'price' => 50.00, + 'quantity' => 2, + 'attributes' => [], + 'conditions' => $discount, + ]); + + // applies_to 0: no items discounted → 50 * 2 = 100 + expect($this->cart->get(1)->getPriceSumWithConditions())->toEqual(100.00); + }); + + test('multiple conditions where only one has applies_to', function () { + $limitedDiscount = new CartCondition([ + 'name' => '10% Off First Item', + 'type' => 'discount', + 'value' => '-10%', + 'applies_to' => 1, + ]); + + $globalDiscount = new CartCondition([ + 'name' => '5% Off All', + 'type' => 'discount', + 'value' => '-5%', + ]); + + $this->cart->add([ + 'id' => 1, + 'name' => 'Widget', + 'price' => 100.00, + 'quantity' => 2, + 'attributes' => [], + 'conditions' => [$limitedDiscount, $globalDiscount], + ]); + + // Item 1: 100 * 0.90 * 0.95 = 85.50 + // Item 2: 100 * 0.95 = 95.00 (only global applies) + // Total: 180.50 + expect($this->cart->get(1)->getPriceSumWithConditions())->toEqual(180.50); + }); + + test('getAppliesToQuantity returns null when not set', function () { + $condition = new CartCondition([ + 'name' => 'Discount', + 'type' => 'discount', + 'value' => '-5%', + ]); + + expect($condition->getAppliesToQuantity())->toBeNull(); + }); + + test('getAppliesToQuantity returns the set value', function () { + $condition = new CartCondition([ + 'name' => 'Discount', + 'type' => 'discount', + 'value' => '-5%', + 'applies_to' => 3, + ]); + + expect($condition->getAppliesToQuantity())->toBe(3); + }); +});