diff --git a/README.md b/README.md index f19bf5d..62d7279 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,9 @@ ### Round Mode 'round_mode' => env('LARAVEL_CART_ROUND_MODE', 'down'), ``` -## HOW TO USE +## Usage -- [Usage](#usage) +- [Basic Usage](#basic-usage) - [Conditions](#conditions) - [Items](#items) - [Associating Models](#associating-models) @@ -288,12 +288,14 @@ ### Clearing the Cart: **Cart::clear()** ## Conditions -Conditions are very useful in terms for adding things like discounts, taxes, shipping costs, etc. +Conditions are very useful for adding things like discounts, taxes, shipping, etc. -Conditions can be added on the entire cart or on individual items. +Conditions can be added on the entire cart or on individual items, and can even be applied only at certain cart values. Conditions on a cart level should always have a `target` of `subtotal` or `total`. This tells the cart which value to apply the condition to. +You can provide an optional `minimum` value which should be the dollar value in which the target (subtotal or total) needs to be for the condition to be active and impact the cart. + You can also provide an `order` to cart conditions which tells the cart in what order to apply the conditions. Item level conditions do not support the `order` parameter. ### Conditions on the cart @@ -362,18 +364,59 @@ #### Getting conditions on the cart: **Cart::getConditions()** $condition->getType(); // the type $condition->getValue(); // the value of the condition $condition->getOrder(); // the order of the condition + $condition->getMinimum(); // the minimum dollar amount of the target, needed to activate the condition + $condition->getMaximum(); // the maximum dollar amount of the target, needed to keep the condition active $condition->getAttributes(); // the attributes of the condition, returns an empty [] if no attributes added } // You can also get a condition that has been applied on the cart by using its name: -$condition = Cart::getCondition('Tax: 10%'); +$condition = Cart::getCondition('GST'); + $condition->getTarget(); // the target of which the condition was applied $condition->getName(); // the name of the condition $condition->getType(); // the type $condition->getValue(); // the value of the condition +$condition->getMinimum(); // the minimum dollar amount of the target, needed to activate the condition +$condition->getMaximum(); // the maximum dollar amount of the target, needed to keep the condition active $condition->getAttributes(); // the attributes of the condition, returns an empty [] if no attributes added ``` +#### Getting active conditions on the cart: **Cart::getConditions(active: true)** + +You can get only active conditions by passsing `active: true` to the `getConditions()` method. + +This will return conditions that are actively being applied to the cart (i.e if they meet their minimum or maximum value) + +```php +$tenPercentOff = new CartCondition([ + 'name' => '10% Off', + 'type' => 'discount', + 'target' => 'subtotal', + 'value' => '-10%', + 'minimum' => 120, + 'order' => 1, +]); + +Cart::getConditions(active: true); + +// will return "10% Off" if the subtotal of the cart is $200. +// will return no conditions if the subtotal is $100. + +$shipping = new CartCondition([ + 'name' => 'Shipping', + 'type' => 'discount', + 'target' => 'subtotal', + 'value' => '10', + 'maximum' => 200, + 'order' => 1, +]); + +Cart::getConditions(active: true); + +// will return "Shipping" if the subtotal of the cart is less than or equal 200 +// will return no conditions if the subtotal is $210 +``` + ### Calculating condition value There are 2 ways to calculate the value of a condition: @@ -385,7 +428,7 @@ #### Using the `getCalculatedValue` method on the condition instance ```php $subTotal = Cart::getSubTotal(); -$condition = Cart::getCondition('VAT 12.5%'); +$condition = Cart::getCondition('10% GST'); $conditionCalculatedValue = $condition->getCalculatedValue($subTotal); ``` @@ -422,6 +465,44 @@ #### Using the `getCalculatedValueForCondition` method on the cart instance Cart::getCalculatedValueForCondition('Gift Card'); // returns 0 as the coupon discount is applied first and brings the subtotal to 0 ``` +### Adding conditions that activate once a minimum value is met + +You can add a `minimum` amount required for a condition to activate. + +This is useful for applying discounts only after certain cart values, i.e: 10% off for any purchases over $120. + +```php +$tenPercentOff = new CartCondition([ + 'name' => '10% Off', + 'type' => 'discount', + 'target' => 'subtotal', + 'value' => '-10%', + 'minimum' => 120, + 'order' => 1, +]); + +Cart::condition($tenPercentOff) +``` + +### Adding conditions that only activate up to a maximum value + +You can add a `maximum` amount required for a condition to activate. + +This is useful for applying discounts up until amounts, i.e shipping for anything below $200, and free shipping above. + +```php +$shipping = new CartCondition([ + 'name' => 'Shipping', + 'type' => 'discount', + 'target' => 'subtotal', + 'value' => '12', + 'maximum' => 200, + 'order' => 1, +]); + +Cart::condition($shipping) +``` + ### Condition on items Item conditions are useful if you have discounts to be applied specifically on an item and not on the whole cart value. @@ -483,7 +564,7 @@ ### Condition on items $cartSubTotal = Cart::getSubTotal(); ``` -#### Add condition to existing Item on the cart: **Cart::addItemCondition($productId, $itemCondition)** +#### Add a condition to an existing item on the cart: **Cart::addItemCondition($productId, $itemCondition)** Adding Condition to an existing Item on the cart is simple as well. diff --git a/src/Cart.php b/src/Cart.php index e6ece4f..c3147b0 100644 --- a/src/Cart.php +++ b/src/Cart.php @@ -10,59 +10,22 @@ class Cart { - /** - * the item storage - */ protected $session; - - /** - * the event dispatcher - */ protected $events; - /** - * the cart session key - */ protected $instanceName; - /** - * the session key use for the cart - */ protected $sessionKey; - - /** - * the session key use to persist cart items - */ protected $sessionKeyCartItems; - - /** - * the session key use to persist cart conditions - */ protected $sessionKeyCartConditions; - /** - * Configuration to pass to ItemCollection - */ protected $config; - /** - * This holds the currently added item id in cart for association - */ protected $currentItemId; - /** - * The number of decimals to use when formatting prices - */ protected $decimals; - - /** - * The decimal point character - */ protected $decPoint; - /** - * our object constructor - */ public function __construct($session, $events, $instanceName, $session_key, $config) { $this->events = $events; @@ -73,6 +36,7 @@ public function __construct($session, $events, $instanceName, $session_key, $con $this->sessionKeyCartConditions = $this->sessionKey . '_cart_conditions'; $this->config = $config; $this->currentItemId = null; + $this->fireEvent('created'); } @@ -109,20 +73,16 @@ public function getInstanceName() /** * get an item on a cart by item ID - * - * @return mixed */ - public function get($itemId) + public function get($itemId): ?ItemCollection { return $this->getContent()->get($itemId); } /** - * check if an item exists by item ID - * - * @return bool + * check if we have this item in the cart by item ID */ - public function has($itemId) + public function has($itemId): bool { return $this->getContent()->has($itemId); } @@ -144,10 +104,9 @@ public function add( array|CartCondition $conditions = [], ?string $associatedModel = null ): self { - // check if its an array of items + // check if we are adding an array of items if (is_array($id)) { - // the first argument is an array, now we will need to check if it is a multi dimensional - // array, if so, we will iterate through each item and call add again + // check if we are adding a multi dimensional array if (Helpers::isMultiArray($id)) { foreach ($id as $item) { $this->add( @@ -175,6 +134,7 @@ public function add( return $this; } + // if we are here, we are adding a single item $data = [ 'id' => $id, 'name' => $name, @@ -191,11 +151,8 @@ public function add( // validate data $item = $this->validate($data); - // get the cart - $inCart = $this->get($id); - // if the item is already in the cart we will just update it - if ($inCart) { + if ($this->get($id)) { $this->update($id, $item); } else { $this->addItem($id, $item); @@ -378,11 +335,31 @@ public function condition(array|CartCondition $condition): self /** * get conditions applied on the cart * - * @return CartConditionCollection + * @param bool $active - If true, only conditions actually applied on the cart are returned */ - public function getConditions() + public function getConditions(bool $active = false): CartConditionCollection { - return new CartConditionCollection($this->session->get($this->sessionKeyCartConditions)); + $conditions = new CartConditionCollection( + $this->session->get($this->sessionKeyCartConditions) + ); + + if ($active) { + return $conditions->filter(function (CartCondition $condition) { + $target = $condition->getTarget(); + + $amount = $target == 'subtotal' ? $this->getSubTotal(false) : $this->getTotal(); + + return $condition->getMinimum() <= $amount; + })->filter(function (CartCondition $condition) { + $target = $condition->getTarget(); + + $amount = $target == 'subtotal' ? $this->getSubTotal(false) : $this->getTotal(); + + return $condition->getMaximum() >= $amount; + }); + } + + return $conditions; } /** @@ -575,6 +552,12 @@ public function getSubTotal($formatted = true): int|float|string ->filter(function (CartCondition $condition) { return $condition->getTarget() === 'subtotal'; }) + ->filter(function (CartCondition $condition) use ($subTotalSum) { + return $condition->getMinimum() === null || $condition->getMinimum() <= $subTotalSum; + }) + ->filter(function (CartCondition $condition) use ($subTotalSum) { + return $condition->getMaximum() === null || $condition->getMaximum() >= $subTotalSum; + }) ->sortBy(function (CartCondition $condition) { return $condition->getOrder(); }); @@ -607,6 +590,15 @@ public function getTotal(): int|float $conditionsForTotal = $this->getConditions() ->filter(function (CartCondition $condition) { return $condition->getTarget() === 'total'; + }) + ->filter(function (CartCondition $condition) use ($subTotal) { + return $condition->getMinimum() === null || $condition->getMinimum() <= $subTotal; + }) + ->filter(function (CartCondition $condition) use ($subTotal) { + return $condition->getMaximum() === null || $condition->getMaximum() >= $subTotal; + }) + ->sortBy(function (CartCondition $condition) { + return $condition->getOrder(); }); // if no conditions were added, just return the sub total diff --git a/src/CartCondition.php b/src/CartCondition.php index a511cc6..4e9106b 100644 --- a/src/CartCondition.php +++ b/src/CartCondition.php @@ -65,6 +65,22 @@ public function getType() return $this->args['type']; } + /** + * Get the minimum value of this condition + */ + public function getMinimum(): int|float|null + { + return $this->args['minimum'] ?? null; + } + + /** + * Get the maximum value of this condition + */ + public function getMaximum(): int|float|null + { + return $this->args['maximum'] ?? null; + } + /** * get the additional attributes of a condition * diff --git a/tests/Unit/CartConditionsTest.php b/tests/Unit/CartConditionsTest.php index 4d43a8d..7c38b98 100644 --- a/tests/Unit/CartConditionsTest.php +++ b/tests/Unit/CartConditionsTest.php @@ -915,6 +915,176 @@ expect($this->cart->getSubTotal())->toEqual(10.00, 'Cart should have sub total of 10.00'); expect($this->cart->getTotal())->toEqual(10.00, 'Cart should have a total of 10.00'); }); + + test('can activate a dollar discount when a minimum amount is met', function () { + $condition = new CartCondition([ + 'name' => 'Get $10 off when you spend $100', + 'type' => 'target', + 'target' => 'total', + 'value' => '-10', + 'minimum' => '100', + ]); + + $this->cart->add([ + 'id' => 1, + 'name' => 'Apple iPhone 15', + 'price' => 90, + 'quantity' => 1, + 'attributes' => [], + ]); + + $this->cart->condition($condition); + + expect($this->cart->getTotal())->toEqual(90, 'Cart should have total of 90'); + + $this->cart->clear(); + + $this->cart->add([ + 'id' => 1, + 'name' => 'Apple iPhone 15', + 'price' => 90, + 'quantity' => 2, + 'attributes' => [], + ]); + + expect($this->cart->getTotal())->toEqual(170, 'Cart should have total of 170'); + }); + + test('can activate a percentage discount when a minimum amount is met', function () { + $condition = new CartCondition([ + 'name' => 'Get 10% off when you spend $100', + 'type' => 'target', + 'target' => 'total', + 'value' => '-10%', + 'minimum' => '100', + ]); + + $this->cart->add([ + 'id' => 1, + 'name' => 'Apple iPhone 15', + 'price' => 90, + 'quantity' => 1, + 'attributes' => [], + ]); + + $this->cart->condition($condition); + + expect($this->cart->getTotal())->toEqual(90, 'Cart should have total of 90'); + + $this->cart->clear(); + + $this->cart->add([ + 'id' => 1, + 'name' => 'Apple iPhone 15', + 'price' => 90, + 'quantity' => 2, + 'attributes' => [], + ]); + + expect($this->cart->getTotal())->toEqual(162, 'Cart should have total of 162'); + }); + + test('can remove a dollar value condition when a maximum is met', function () { + $condition = new CartCondition([ + 'name' => 'Shipping', + 'type' => 'target', + 'target' => 'total', + 'value' => '18', + 'maximum' => '160', + ]); + + $this->cart->add([ + 'id' => 1, + 'name' => 'Apple iPhone 15', + 'price' => 90, + 'quantity' => 1, + 'attributes' => [], + ]); + + $this->cart->condition($condition); + + expect($this->cart->getTotal())->toEqual(108, 'Cart should have total of 108'); + + $this->cart->clear(); + + $this->cart->add([ + 'id' => 1, + 'name' => 'Apple iPhone 15', + 'price' => 90, + 'quantity' => 2, + 'attributes' => [], + ]); + + expect($this->cart->getTotal())->toEqual(180, 'Cart should have total of 180'); + }); + + test('can remove a percentage condition when a maximum is met', function () { + $condition = new CartCondition([ + 'name' => 'Low Cart Tax', + 'type' => 'target', + 'target' => 'total', + 'value' => '10%', + 'maximum' => '140', + ]); + + $this->cart->add([ + 'id' => 1, + 'name' => 'Apple iPhone 15', + 'price' => 90, + 'quantity' => 1, + 'attributes' => [], + ]); + + $this->cart->condition($condition); + + expect($this->cart->getTotal())->toEqual(99, 'Cart should have total of 99'); + + $this->cart->clear(); + + $this->cart->add([ + 'id' => 1, + 'name' => 'Apple iPhone 15', + 'price' => 90, + 'quantity' => 2, + 'attributes' => [], + ]); + + expect($this->cart->getTotal())->toEqual(180, 'Cart should have total of 180'); + }); + + test('return cart conditions only when they are active', function () { + $condition = new CartCondition([ + 'name' => 'Get $10 off when you spend $100', + 'type' => 'target', + 'target' => 'total', + 'value' => '-10', + 'minimum' => '100', + ]); + + $this->cart->add([ + 'id' => 1, + 'name' => 'Apple iPhone 15', + 'price' => 90, + 'quantity' => 1, + 'attributes' => [], + ]); + + $this->cart->condition($condition); + + expect($this->cart->getConditions(active: true))->toHaveCount(0, 'Cart should have 0 conditions'); + + $this->cart->clear(); + + $this->cart->add([ + 'id' => 1, + 'name' => 'Apple iPhone 15', + 'price' => 90, + 'quantity' => 2, + 'attributes' => [], + ]); + + expect($this->cart->getConditions(active: true))->toHaveCount(1, 'Cart should have 1 condition'); + }); }); describe('item level conditions', function () {