├── .circleci └── config.yml ├── .gitignore ├── .scrutinizer.yml ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── composer.json ├── composer.lock ├── database └── migrations │ ├── 2020_12_13_000001_create_carts_table.php │ └── 2020_12_13_000002_create_cart_items_table.php ├── phpunit.xml.dist ├── phpunit.xml.dist.bak ├── routes └── checkout.php ├── src ├── Checkout.php ├── Contracts │ ├── CartLogistics.php │ ├── DiscountLogistics.php │ ├── Purchaseable.php │ ├── Purchaser.php │ ├── ShippingLogistics.php │ └── TaxLogistics.php ├── Events │ ├── CartItemAdded.php │ ├── CartItemDeleted.php │ └── CartItemUpdated.php ├── Exceptions │ ├── CheckoutMissingInfoException.php │ ├── CheckoutNotFoundException.php │ ├── ItemNotPurchaseableException.php │ ├── PurchaseableNotFoundException.php │ └── PurchaserInvalidException.php ├── Http │ ├── Controllers │ │ ├── Checkout │ │ │ ├── CheckoutController.php │ │ │ ├── CheckoutDiscountController.php │ │ │ └── CheckoutItemController.php │ │ ├── Controller.php │ │ └── Published │ │ │ ├── CheckoutController.php │ │ │ ├── CheckoutDiscountController.php │ │ │ └── CheckoutItemController.php │ ├── Requests │ │ ├── APIRequest.php │ │ ├── CheckoutDiscountRequest.php │ │ ├── CheckoutItemCreateRequest.php │ │ ├── CheckoutItemUpdateRequest.php │ │ └── CheckoutUpdateRequest.php │ └── Resources │ │ └── CheckoutResource.php ├── Logistics │ ├── CartLogistics.php │ ├── DiscountLogistics.php │ ├── ShippingLogistics.php │ └── TaxLogistics.php ├── Models │ ├── Cart.php │ └── CartItem.php ├── ShoppingCartServiceProvider.php └── Traits │ ├── Purchaseable.php │ └── Purchaser.php └── tests ├── Factories ├── CartFactory.php ├── CartItemFactory.php ├── Customer.php ├── NonPurchaseable.php ├── NonPurchaser.php └── ProductFactory.php ├── Feature ├── Api │ ├── CheckoutItemTest.php │ └── CheckoutTest.php └── CheckoutTest.php ├── Logistics ├── CartLogistics.php ├── CartLogisticsMissingTotals.php ├── DiscountLogistics.php ├── ShippingLogistics.php └── TaxLogistics.php ├── Migrations ├── 2020_12_15_000001_create_products_table.php └── 2020_12_31_000001_create_customers_table.php ├── Models ├── Customer.php ├── NonPurchaseable.php ├── NonPurchaser.php └── Product.php ├── ShoppingCartTestProvider.php ├── TestCase.php └── Unit └── CartTest.php /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # PHP CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-php/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # Specify the version you desire here 10 | - image: circleci/php:8.0-cli 11 | 12 | steps: 13 | - checkout 14 | 15 | - run: sudo apt update 16 | - run: sudo docker-php-ext-install zip 17 | 18 | # Download and cache dependencies 19 | - restore_cache: 20 | keys: 21 | - v1-dependencies-{{ checksum "composer.json" }} 22 | # fallback to using the latest cache if no exact match is found 23 | - v1-dependencies- 24 | 25 | - run: composer install -n --prefer-dist 26 | 27 | - save_cache: 28 | paths: 29 | - ./vendor 30 | key: v1-dependencies-{{ checksum "composer.json" }} 31 | 32 | # run tests! 33 | - run: vendor/bin/phpunit -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | .phpunit.result.cache 3 | build/report.junit.xml 4 | .vscode/ -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | filter: 2 | excluded_paths: [tests/*] 3 | 4 | checks: 5 | php: 6 | remove_extra_empty_lines: true 7 | remove_php_closing_tag: true 8 | remove_trailing_whitespace: true 9 | fix_use_statements: 10 | remove_unused: true 11 | preserve_multiple: false 12 | preserve_blanklines: true 13 | order_alphabetically: true 14 | fix_php_opening_tag: true 15 | fix_linefeed: true 16 | fix_line_ending: true 17 | fix_identation_4spaces: true 18 | fix_doc_comments: true 19 | 20 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | Please read and understand the contribution guide before creating an issue or pull request. 6 | 7 | ## Etiquette 8 | 9 | This project is open source, and as such, the maintainers give their free time to build and maintain the source code 10 | held within. They make the code freely available in the hope that it will be of use to other developers. It would be 11 | extremely unfair for them to suffer abuse or anger for their hard work. 12 | 13 | Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the 14 | world that developers are civilized and selfless people. 15 | 16 | It's the duty of the maintainer to ensure that all submissions to the project are of sufficient 17 | quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. 18 | 19 | ## Viability 20 | 21 | When requesting or submitting new features, first consider whether it might be useful to others. Open 22 | source projects are used by many developers, who may have entirely different needs to your own. Think about 23 | whether or not your feature is likely to be used by other users of the project. 24 | 25 | ## Procedure 26 | 27 | Before filing an issue: 28 | 29 | - Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. 30 | - Check to make sure your feature suggestion isn't already present within the project. 31 | - Check the pull requests tab to ensure that the bug doesn't have a fix in progress. 32 | - Check the pull requests tab to ensure that the feature isn't already in progress. 33 | 34 | Before submitting a pull request: 35 | 36 | - Check the codebase to ensure that your feature doesn't already exist. 37 | - Check the pull requests to ensure that another person hasn't already submitted the feature or fix. 38 | 39 | ## Requirements 40 | 41 | If the project maintainer has any additional requirements, you will find them listed here. 42 | 43 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](https://pear.php.net/package/PHP_CodeSniffer). 44 | 45 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 46 | 47 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 48 | 49 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is not an option. 50 | 51 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 52 | 53 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 54 | 55 | **Happy coding**! 56 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Yab Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/yabhq/laravel-cart.svg?style=flat-square)](https://packagist.org/packages/yabhq/laravel-cart) 2 | [![CircleCI](https://circleci.com/gh/yabhq/laravel-cart.svg?style=svg)](https://circleci.com/gh/yabhq/laravel-cart) 3 | 4 | # Laravel Shopping Cart 5 | 6 | A simple yet customizable Laravel shopping cart implementation. 7 | 8 | Provides RESTful API endpoints out of the box to help with front-end / SPA integrations. 9 | 10 | ## Table of Contents 11 | 12 | [Requirements](#requirements) 13 | [Installation](#installation) 14 | [Usage](#usage) 15 | [The Checkout Class](#the-checkout-class) 16 | [Customization](#customization) 17 | [License](#license) 18 | 19 | ## Requirements 20 | 21 | - PHP 8+ 22 | - Laravel 8.x 23 | 24 | ## Installation 25 | 26 | ```bash 27 | composer require yabhq/laravel-cart 28 | ``` 29 | 30 | The package publishes some migrations, routes (for optional use) and classes for further customizing your store logistics. 31 | 32 | ```bash 33 | php artisan vendor:publish --provider="Yab\ShoppingCart\ShoppingCartServiceProvider" 34 | ``` 35 | 36 | Full list of published files: 37 | 38 | - database/migrations/2020_12_13_000001_create_carts_table 39 | - database/migrations/2020_12_13_000002_create_cart_items_table 40 | - routes/checkout.php 41 | - config/checkout.php 42 | - app/Logistics/CartLogistics.php 43 | - app/Logistics/ShippingLogistics.php 44 | - app/Logistics/TaxLogistics.php 45 | - app/Logistics/DiscountLogistics.php 46 | 47 | ## Usage 48 | 49 | First, simply implement the _Purchaseable_ interface on your product (or other purchaseable) model. 50 | 51 | **app/Models/Product.php** 52 | 53 | ```php 54 | use Yab\ShoppingCart\Traits\Purchaseable; 55 | use Yab\ShoppingCart\Contracts\Purchaseable as PurchaseableInterface; 56 | 57 | class Product extends Model implements PurchaseableInterface 58 | { 59 | use Purchaseable; 60 | } 61 | ``` 62 | 63 | Next we should implement the _Purchaser_ interface on the model representing the end customer. 64 | 65 | **app/Models/Customer.php** 66 | 67 | ```php 68 | use Yab\ShoppingCart\Traits\Purchaser; 69 | use Yab\ShoppingCart\Contracts\Purchaser as PurchaserInterface; 70 | 71 | class Customer extends Model implements PurchaserInterface 72 | { 73 | use Purchaser; 74 | } 75 | ``` 76 | 77 | If you would like to use the built-in cart API endpoints, you can simply include the published _checkout.php_ in your existing routes file. 78 | 79 | **routes/api.php** (optional) 80 | 81 | ```php 82 | Route::group(['middleware' => ['example']], function () { 83 | require base_path('routes/checkout.php'); 84 | }); 85 | ``` 86 | 87 | ## The Checkout Class 88 | 89 | The package comes with a _Checkout_ class which allows you to interact with the shopping cart. 90 | 91 | ```php 92 | use Yab\ShoppingCart\Checkout; 93 | ``` 94 | 95 | Creating or retrieving a checkout instance: 96 | 97 | ```php 98 | $checkout = Checkout::create(); 99 | // or 100 | $checkout = Checkout::findById('uuid-123'); 101 | ``` 102 | 103 | Getting the ID of an existing checkout: 104 | 105 | ```php 106 | $checkout->id(); 107 | ``` 108 | 109 | Adding a custom field for a checkout: 110 | 111 | ```php 112 | $checkout->setCustomField('some key', 'some value'); 113 | ``` 114 | 115 | Deleting a checkout: 116 | 117 | ```php 118 | $checkout->destroy(); 119 | ``` 120 | 121 | Interacting with the underlying cart model and query builder: 122 | 123 | ```php 124 | // Yab\ShoppingCart\Models\Cart 125 | $checkout->getCart(); 126 | 127 | // Illuminate\Database\Eloquent\Builder 128 | $checkout->getCartBuilder(); 129 | ``` 130 | 131 | Adding, updating or removing cart items: 132 | 133 | ```php 134 | // Add 1 qty of product and return the CartItem model 135 | $item = $checkout->addItem($product, 1); 136 | 137 | // Override the default unit price for the product 138 | $item = $checkout->addItem($product, 1, 11.95); 139 | 140 | // Add custom options to a checkout item 141 | $item = $checkout->addItem( 142 | purchaseable: $product, 143 | qty: 1, 144 | options: [ 'size' => 'medium' ], 145 | ); 146 | 147 | // Update the quantity of the item to 2 148 | $checkout->updateItem($item->id, 2); 149 | 150 | // Remove the item entirely 151 | $checkout->removeItem($item->id); 152 | ``` 153 | 154 | Optionally set a purchaser entity (class must implement Purchaser interface): 155 | 156 | ```php 157 | $checkout->setPurchaser($customer); 158 | ``` 159 | 160 | Getting the shipping, subtotal, taxes and total: 161 | 162 | ```php 163 | $checkout->getShipping(); // 5.00 164 | $checkout->getSubtotal(); // 110.00 165 | $checkout->getDiscount(); // 10.00 166 | $checkout->getTaxes(); // 13.00 167 | $checkout->getTotal(); // 113.00 168 | ``` 169 | 170 | ## Customization 171 | 172 | Not every e-commerce store is the same. This package provides several "logistics" classes which allow you to hook into the core package logic and perform some common customizations. For example, you may specify how the tax, shipping and discount amounts are determined: 173 | 174 | **app/Logistics/TaxLogistics.php** 175 | 176 | ```php 177 | public static function getTaxes(Checkout $checkout) : float 178 | ``` 179 | 180 | **app/Logistics/ShippingLogistics.php** 181 | 182 | ```php 183 | public static function getShippingCost(Checkout $checkout) : float 184 | ``` 185 | 186 | **app/Logistics/DiscountLogistics.php** 187 | 188 | ```php 189 | public static function getDiscountFromCode(Checkout $checkout, string $code) : float 190 | ``` 191 | 192 | **app/Logistics/CartLogistics.php** 193 | 194 | ```php 195 | public static function getPurchaseable(string $type, mixed $id) : mixed 196 | public static function beforeCartItemAdded(Checkout $checkout, mixed $purchaseable, int $qty) : void 197 | public static function hasInfoNeededToCalculateTotal(Checkout $checkout) : bool 198 | ``` 199 | 200 | ## License 201 | 202 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 203 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yabhq/laravel-cart", 3 | "description": "Simple yet customizable Laravel shopping cart", 4 | "keywords": ["laravel", "cart", "shopping", "shopping cart"], 5 | "homepage": "https://github.com/yabhq/laravel-cart", 6 | "license": "MIT", 7 | "type": "library", 8 | "authors": [ 9 | { 10 | "name": "Jim", 11 | "email": "jimhlad@gmail.com" 12 | }, 13 | { 14 | "name": "Chris", 15 | "email": "chris@chrisblackwell.me" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.0", 20 | "illuminate/support": "^8.0", 21 | "yabhq/laravel-mint": "^1.0.2" 22 | }, 23 | "require-dev": { 24 | "laravel/legacy-factories": "^1.0.4", 25 | "orchestra/testbench": "^6.0", 26 | "phpunit/phpunit": "^9.0" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "Yab\\ShoppingCart\\": "src" 31 | } 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "Yab\\ShoppingCart\\Tests\\": "tests", 36 | "App\\Http\\Controllers\\Checkout\\": "src/Http/Controllers/Published" 37 | } 38 | }, 39 | "minimum-stability": "dev", 40 | "prefer-stable": true, 41 | "scripts": { 42 | "test": "vendor/bin/phpunit", 43 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage" 44 | }, 45 | "config": { 46 | "sort-packages": true 47 | }, 48 | "extra": { 49 | "laravel": { 50 | "providers": [ 51 | "Yab\\ShoppingCart\\ShoppingCartServiceProvider" 52 | ] 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /database/migrations/2020_12_13_000001_create_carts_table.php: -------------------------------------------------------------------------------- 1 | uuid('id')->primary(); 18 | $table->nullableMorphs('purchaser'); 19 | $table->string('discount_code')->nullable(); 20 | $table->integer('discount_amount')->default(0); 21 | $table->json('custom_fields')->nullable(); 22 | $table->timestamps(); 23 | $table->softDeletes(); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | * 30 | * @return void 31 | */ 32 | public function down() 33 | { 34 | Schema::dropIfExists('carts'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /database/migrations/2020_12_13_000002_create_cart_items_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 18 | $table->string('cart_id')->index(); 19 | $table->foreign('cart_id')->references('id')->on('carts'); 20 | $table->morphs('purchaseable'); 21 | $table->integer('qty')->default(1); 22 | $table->integer('unit_price'); 23 | $table->integer('price'); 24 | $table->json('custom_fields')->nullable(); 25 | $table->timestamps(); 26 | $table->softDeletes(); 27 | }); 28 | } 29 | 30 | /** 31 | * Reverse the migrations. 32 | * 33 | * @return void 34 | */ 35 | public function down() 36 | { 37 | Schema::dropIfExists('cart_items'); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | src/ 6 | 7 | 8 | 9 | 10 | tests 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /phpunit.xml.dist.bak: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | tests 15 | 16 | 17 | 18 | 19 | src/ 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /routes/checkout.php: -------------------------------------------------------------------------------- 1 | 'Yab\ShoppingCart\Http\Controllers\Checkout', 9 | ], function () { 10 | Route::post('checkout', [CheckoutController::class, 'store'])->name('checkout.store'); 11 | Route::get('checkout/{checkout}', [CheckoutController::class, 'show'])->name('checkout.show'); 12 | Route::put('checkout/{checkout}', [CheckoutController::class, 'update'])->name('checkout.update'); 13 | Route::delete('checkout/{checkout}', [CheckoutController::class, 'destroy'])->name('checkout.destroy'); 14 | 15 | Route::post('checkout/{checkout}/items', [CheckoutItemController::class, 'store'])->name('checkout.items.store'); 16 | Route::put('checkout/{checkout}/items/{itemId}', [CheckoutItemController::class, 'update'])->name('checkout.items.update'); 17 | Route::delete('checkout/{checkout}/items/{itemId}', [CheckoutItemController::class, 'destroy'])->name('checkout.items.destroy'); 18 | 19 | Route::post('checkout/{checkout}/discount', [CheckoutDiscountController::class, 'store'])->name('checkout.discount'); 20 | }); 21 | -------------------------------------------------------------------------------- /src/Checkout.php: -------------------------------------------------------------------------------- 1 | find($checkoutId) : Cart::find($checkoutId); 45 | 46 | if (! $checkout) { 47 | throw new CheckoutNotFoundException(); 48 | } 49 | 50 | return new Checkout($checkout); 51 | } 52 | 53 | /** 54 | * Create a fresh new checkout with a new ID. 55 | * 56 | * @return \Yab\ShoppingCart\Checkout 57 | */ 58 | public static function create() : Checkout 59 | { 60 | return new Checkout(Cart::create()); 61 | } 62 | 63 | /** 64 | * Get the UUID for this checkout. 65 | * 66 | * @return string 67 | */ 68 | public function id() : string 69 | { 70 | return $this->getCart()->id; 71 | } 72 | 73 | /** 74 | * Destroy this checkout instance and soft delete the checkout. 75 | * 76 | * @return void 77 | */ 78 | public function destroy() 79 | { 80 | $this->cart->delete(); 81 | 82 | unset($this->cart); 83 | } 84 | 85 | /** 86 | * Get the underlying cart model for this checkout instance. 87 | * 88 | * @return \Yab\ShoppingCart\Models\Cart 89 | */ 90 | public function getCart() : Cart 91 | { 92 | return $this->cart->fresh(); 93 | } 94 | 95 | /** 96 | * Get the underlying builder instance for the cart. 97 | * 98 | * @return \Illuminate\Database\Eloquent\Builder 99 | */ 100 | public function getCartBuilder() : Builder 101 | { 102 | return Cart::whereId($this->cart->id); 103 | } 104 | 105 | /** 106 | * Get the purchaseable entity given the purchaseable entity type and ID. 107 | * 108 | * @param string $type 109 | * @param mixed $id 110 | * 111 | * @return mixed 112 | */ 113 | public static function getPurchaseable(string $type, mixed $id) : mixed 114 | { 115 | return app(CartLogistics::class)->getPurchaseable($type, $id); 116 | } 117 | 118 | /** 119 | * Set the purchaser for the checkout. 120 | * 121 | * @param mixed $entity 122 | * 123 | * @return void 124 | */ 125 | public function setPurchaser(mixed $entity) 126 | { 127 | $this->abortIfNotPurchaser($entity); 128 | 129 | $this->cart->purchaser_id = $entity->getIdentifier(); 130 | $this->cart->purchaser_type = $entity->getType(); 131 | 132 | $this->cart->save(); 133 | } 134 | 135 | /** 136 | * Get the purchaser for the checkout. 137 | * 138 | * @return mixed 139 | */ 140 | public function getPurchaser() 141 | { 142 | return $this->cart->purchaser; 143 | } 144 | 145 | /** 146 | * Add an item to the cart. 147 | * 148 | * @param mixed $purchaseable 149 | * @param int $qty 150 | * @param float $price - optional 151 | * @param array $options - optional 152 | * 153 | * @return \Yab\ShoppingCart\Models\CartItem 154 | */ 155 | public function addItem(mixed $purchaseable, int $qty, ?float $price = null, ?array $options = []) : CartItem 156 | { 157 | $this->abortIfNotPurchaseable($purchaseable); 158 | 159 | app(CartLogistics::class)->beforeCartItemAdded($this, $purchaseable, $qty); 160 | 161 | $item = $this->cart->getItem($purchaseable); 162 | $item->setQty($qty)->setOptions($options)->calculatePrice($price)->save(); 163 | 164 | event(new CartItemAdded($item)); 165 | 166 | return $item; 167 | } 168 | 169 | /** 170 | * Update an existing item in the cart. 171 | * 172 | * @param int $cartItemId 173 | * @param int $qty 174 | * @param float $price - optional 175 | * @param array $options - optional 176 | * 177 | * @return \Yab\ShoppingCart\Models\CartItem 178 | */ 179 | public function updateItem(int $cartItemId, int $qty, ?float $price = null, ?array $options = []) : CartItem 180 | { 181 | $item = CartItem::findOrFail($cartItemId); 182 | $item->setQty($qty)->setOptions($options)->calculatePrice($price)->save(); 183 | 184 | event(new CartItemUpdated($item)); 185 | 186 | return $item; 187 | } 188 | 189 | /** 190 | * Remove an existing item from the cart. 191 | * 192 | * @param int $cartItemId 193 | * 194 | * @return \Yab\ShoppingCart\Models\CartItem 195 | */ 196 | public function removeItem(int $cartItemId) : CartItem 197 | { 198 | $item = CartItem::findOrFail($cartItemId); 199 | $item->delete(); 200 | 201 | event(new CartItemDeleted($item)); 202 | 203 | return $item; 204 | } 205 | 206 | /** 207 | * Set a custom field value for this cart. 208 | * 209 | * @param string $key 210 | * @param mixed $payload 211 | * 212 | * @return \Yab\ShoppingCart\Checkout 213 | */ 214 | public function setCustomField(string $key, mixed $payload) : Checkout 215 | { 216 | $this->cart->setCustomField($key, $payload); 217 | 218 | return $this; 219 | } 220 | 221 | /** 222 | * Get the custom field value for the specified key. 223 | * 224 | * @param string $key 225 | * 226 | * @return mixed 227 | */ 228 | public function getCustomField(string $key) : mixed 229 | { 230 | if (!$this->cart->custom_fields) { 231 | return null; 232 | } 233 | 234 | if (Str::contains($key, '.')) { 235 | $flattened = Arr::dot($this->cart->custom_fields); 236 | return isset($flattened[$key]) ? $flattened[$key] : null; 237 | } 238 | 239 | return isset($this->cart->custom_fields[$key]) ? $this->cart->custom_fields[$key] : null; 240 | } 241 | 242 | /** 243 | * Apply a discount code to this checkout. 244 | * 245 | * @param string $code 246 | * 247 | * @return \Yab\ShoppingCart\Checkout 248 | */ 249 | public function applyDiscountCode(string $code) : Checkout 250 | { 251 | $amount = app(DiscountLogistics::class)->getDiscountFromCode($this, $code); 252 | 253 | if ($amount == 0) { 254 | return $this; 255 | } 256 | 257 | $this->setDiscountCode($code); 258 | $this->setDiscountAmount($amount); 259 | 260 | return $this; 261 | } 262 | 263 | /** 264 | * Manually set the discount amount for the checkout (e.g. without 265 | * applying a specific code). 266 | * 267 | * @param float $amount 268 | * 269 | * @return \Yab\ShoppingCart\Checkout 270 | */ 271 | public function setDiscountAmount(float $amount) : Checkout 272 | { 273 | $this->cart->discount_amount = $amount; 274 | $this->cart->save(); 275 | 276 | return $this; 277 | } 278 | 279 | /** 280 | * Whether or not this checkout has the info needed to calculate the total. 281 | * 282 | * @return bool 283 | */ 284 | public function hasInfoNeededToCalculateTotal() : bool 285 | { 286 | return app(CartLogistics::class)->hasInfoNeededToCalculateTotal($this); 287 | } 288 | 289 | /** 290 | * Get the shipping cost for the checkout. 291 | * 292 | * @return float 293 | */ 294 | public function getShipping() : float 295 | { 296 | return round(app(ShippingLogistics::class)->getShippingCost($this), 2); 297 | } 298 | 299 | /** 300 | * Get the subtotal for the checkout. 301 | * 302 | * @return float 303 | */ 304 | public function getSubtotal() : float 305 | { 306 | return round($this->getCart()->items->sum('price') + $this->getShipping(), 2); 307 | } 308 | 309 | /** 310 | * Get the discount amount (dollars) for the checkout. 311 | * 312 | * @return float 313 | */ 314 | public function getDiscount() : float 315 | { 316 | return floatval($this->cart->discount_amount); 317 | } 318 | 319 | /** 320 | * Get the taxes for the checkout. 321 | * 322 | * @return float 323 | */ 324 | public function getTaxes() : float 325 | { 326 | return round(app(TaxLogistics::class)->getTaxes($this), 2); 327 | } 328 | 329 | /** 330 | * Get the total for the checkout. 331 | * 332 | * @return float 333 | */ 334 | public function getTotal() : float 335 | { 336 | return round($this->getSubtotal() - $this->getDiscount() + $this->getTaxes(), 2); 337 | } 338 | 339 | /** 340 | * Manually tag this checkout with a discount code. 341 | * 342 | * @param string $code 343 | * 344 | * @return \Yab\ShoppingCart\Checkout 345 | */ 346 | private function setDiscountCode(string $code) : Checkout 347 | { 348 | $this->cart->discount_code = $code; 349 | $this->cart->save(); 350 | 351 | return $this; 352 | } 353 | 354 | /** 355 | * Throw an exception if the payload does not implement the purchaseable 356 | * interface. 357 | * 358 | * @param mixed $purchaseable 359 | * 360 | * @throws \Yab\ShoppingCart\Exceptions\ItemNotPurchaseableException 361 | * 362 | * @return void 363 | */ 364 | private function abortIfNotPurchaseable(mixed $purchaseable) 365 | { 366 | if (!($purchaseable instanceof Purchaseable)) { 367 | throw new ItemNotPurchaseableException; 368 | } 369 | } 370 | 371 | /** 372 | * Throw an exception if the payload does not implement the purchaser 373 | * interface. 374 | * 375 | * @param mixed $purchaser 376 | * 377 | * @throws \Yab\ShoppingCart\Exceptions\PurchaserInvalidException 378 | * 379 | * @return void 380 | */ 381 | private function abortIfNotPurchaser(mixed $purchaser) 382 | { 383 | if (!($purchaser instanceof Purchaser)) { 384 | throw new PurchaserInvalidException; 385 | } 386 | } 387 | } 388 | -------------------------------------------------------------------------------- /src/Contracts/CartLogistics.php: -------------------------------------------------------------------------------- 1 | json([ 29 | 'message' => 'The checkout is missing required information', 30 | ], Response::HTTP_INTERNAL_SERVER_ERROR); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Exceptions/CheckoutNotFoundException.php: -------------------------------------------------------------------------------- 1 | json([ 29 | 'message' => 'The specified checkout does not exist', 30 | ], Response::HTTP_NOT_FOUND); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Exceptions/ItemNotPurchaseableException.php: -------------------------------------------------------------------------------- 1 | json([ 29 | 'message' => 'The purchaseable item could not be found', 30 | ], Response::HTTP_NOT_FOUND); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Exceptions/PurchaserInvalidException.php: -------------------------------------------------------------------------------- 1 | custom_fields) { 56 | foreach ($request->custom_fields as $key => $value) { 57 | $checkout->setCustomField($key, $value); 58 | } 59 | } 60 | 61 | return new CheckoutResource($checkout); 62 | } 63 | 64 | /** 65 | * Delete the contents for a particular checkout ID. 66 | * 67 | * @param \Illuminate\Http\Request $request 68 | * @param string $checkoutId 69 | * 70 | * @return \Illuminate\Http\Response 71 | */ 72 | public function destroy(Request $request, string $checkoutId) 73 | { 74 | $checkout = Checkout::findById($checkoutId); 75 | 76 | $checkout->destroy(); 77 | 78 | return response()->make('', Response::HTTP_NO_CONTENT); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Http/Controllers/Checkout/CheckoutDiscountController.php: -------------------------------------------------------------------------------- 1 | applyDiscountCode($request->code); 25 | 26 | return new CheckoutResource($checkout); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Http/Controllers/Checkout/CheckoutItemController.php: -------------------------------------------------------------------------------- 1 | purchaseable_type, 28 | $request->purchaseable_id, 29 | ); 30 | 31 | throw_if(!$purchaseable, PurchaseableNotFoundException::class); 32 | 33 | return $checkout->addItem( 34 | purchaseable: $purchaseable, 35 | qty: $request->qty, 36 | options: $request->options ?? [], 37 | ); 38 | } 39 | 40 | /** 41 | * Update an existing item in the cart. 42 | * 43 | * @param \Yab\ShoppingCart\Http\Requests\CheckoutItemUpdateRequest $request 44 | * @param string $checkoutId 45 | * @param int $itemId 46 | * 47 | * @return \Illuminate\Http\Response 48 | */ 49 | public function update(CheckoutItemUpdateRequest $request, string $checkoutId, int $itemId) 50 | { 51 | $checkout = Checkout::findById($checkoutId); 52 | 53 | return $checkout->updateItem( 54 | cartItemId: $itemId, 55 | qty: $request->qty, 56 | options: $request->options ?? [], 57 | ); 58 | } 59 | 60 | /** 61 | * Remove an existing item from the cart. 62 | * 63 | * @param \Illuminate\Http\Request $request 64 | * @param string $checkoutId 65 | * @param int $itemId 66 | * 67 | * @return \Illuminate\Http\Response 68 | */ 69 | public function destroy(Request $request, string $checkoutId, int $itemId) 70 | { 71 | $checkout = Checkout::findById($checkoutId); 72 | 73 | return $checkout->removeItem($itemId); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Http/Controllers/Controller.php: -------------------------------------------------------------------------------- 1 | false, 23 | 'message' => 'The provided data failed validation', 24 | 'errors' => $validator->errors() 25 | ], Response::HTTP_BAD_REQUEST); 26 | 27 | throw new ValidationException($validator, $response); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Http/Requests/CheckoutDiscountRequest.php: -------------------------------------------------------------------------------- 1 | 'required', 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Http/Requests/CheckoutItemCreateRequest.php: -------------------------------------------------------------------------------- 1 | 'required', 28 | 'purchaseable_id' => 'required', 29 | 'qty' => 'required|integer', 30 | 'options' => 'sometimes|nullable|array', 31 | ]; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Http/Requests/CheckoutItemUpdateRequest.php: -------------------------------------------------------------------------------- 1 | 'required|integer', 28 | 'options' => 'sometimes|nullable|array', 29 | ]; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Http/Requests/CheckoutUpdateRequest.php: -------------------------------------------------------------------------------- 1 | 'sometimes|array', 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Http/Resources/CheckoutResource.php: -------------------------------------------------------------------------------- 1 | $this->getSubtotal(), 20 | 'cart' => $this->getCart(), 21 | ]; 22 | 23 | if ($this->hasInfoNeededToCalculateTotal()) { 24 | $arr[] = $this->getCheckoutTotals(); 25 | } 26 | 27 | return $arr; 28 | } 29 | 30 | /** 31 | * Get the shipping, discount, taxes and total for the checkout. 32 | * 33 | * @return array 34 | */ 35 | private function getCheckoutTotals() : array 36 | { 37 | return [ 38 | 'shipping' => $this->getShipping(), 39 | 'discount' => $this->getDiscount(), 40 | 'taxes' => $this->getTaxes(), 41 | 'total' => $this->getTotal(), 42 | ]; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Logistics/CartLogistics.php: -------------------------------------------------------------------------------- 1 | getSubtotal() 27 | 28 | return 0; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Logistics/ShippingLogistics.php: -------------------------------------------------------------------------------- 1 | getCustomField('shipping_address') 22 | // $checkout->getCart() 23 | 24 | return 0; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Logistics/TaxLogistics.php: -------------------------------------------------------------------------------- 1 | getSubtotal() 22 | // $checkout->getDiscount() 23 | // $checkout->getCustomField('shipping_address') 24 | // $checkout->getCart() 25 | 26 | return 0; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Models/Cart.php: -------------------------------------------------------------------------------- 1 | Money::class, 55 | 'custom_fields' => 'array', 56 | ]; 57 | 58 | /** 59 | * The purchaser entity for this checkout. 60 | * 61 | * @return \Illuminate\Database\Eloquent\Relations\MorphTo 62 | */ 63 | public function purchaser() : MorphTo 64 | { 65 | return $this->morphTo(); 66 | } 67 | 68 | /** 69 | * A cart may have many line items. 70 | * 71 | * @return \Illuminate\Database\Eloquent\Relations\HasMany 72 | */ 73 | public function items() : HasMany 74 | { 75 | return $this->hasMany(CartItem::class); 76 | } 77 | 78 | /** 79 | * Create or retrieve the cart item for the given purchaseable. 80 | * 81 | * @param mixed $purchaseable 82 | * 83 | * @return \Yab\ShoppingCart\Models\CartItem 84 | */ 85 | public function getItem(mixed $purchaseable) : CartItem 86 | { 87 | return $this->items()->firstOrNew([ 88 | 'purchaseable_id' => $purchaseable->getIdentifier(), 89 | 'purchaseable_type' => $purchaseable->getType(), 90 | ]); 91 | } 92 | 93 | /** 94 | * Set a custom field value for this cart. 95 | * 96 | * @param string $key 97 | * @param mixed $payload 98 | * 99 | * @return \Yab\ShoppingCart\Models\Cart 100 | */ 101 | public function setCustomField(string $key, mixed $payload) : Cart 102 | { 103 | $custom = $this->custom_fields; 104 | $custom[$key] = $payload; 105 | 106 | $this->custom_fields = $custom; 107 | $this->save(); 108 | 109 | return $this; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Models/CartItem.php: -------------------------------------------------------------------------------- 1 | 'integer', 35 | 'unit_price' => Money::class, 36 | 'price' => Money::class, 37 | 'custom_fields' => 'array', 38 | ]; 39 | 40 | /** 41 | * A cart item belongs to a cart. 42 | * 43 | * @return \Illuminate\Database\Eloquent\Relations\BelongsTo 44 | */ 45 | public function cart() : BelongsTo 46 | { 47 | return $this->belongsTo(Cart::class); 48 | } 49 | 50 | /** 51 | * The actual item (e.g. product) that was purchased. 52 | * 53 | * @return \Illuminate\Database\Eloquent\Relations\MorphTo 54 | */ 55 | public function purchaseable() : MorphTo 56 | { 57 | return $this->morphTo(); 58 | } 59 | 60 | /** 61 | * Set the quantity for this item. 62 | * 63 | * @param integer $qty 64 | * 65 | * @return \Yab\ShoppingCart\Models\CartItem 66 | */ 67 | public function setQty(int $qty) : CartItem 68 | { 69 | $this->qty = $qty; 70 | 71 | return $this; 72 | } 73 | 74 | /** 75 | * Set the custom options for this item 76 | * 77 | * @param array $options 78 | * 79 | * @return \Yab\ShoppingCart\Models\CartItem 80 | */ 81 | public function setOptions(array $options) : CartItem 82 | { 83 | $custom = $this->custom_fields; 84 | $custom['options'] = $options; 85 | 86 | $this->custom_fields = $custom; 87 | 88 | return $this; 89 | } 90 | 91 | /** 92 | * Calculate the price for this line item based on the quantity. 93 | * 94 | * @param float|null $unitPrice 95 | * 96 | * @return \Yab\ShoppingCart\Models\CartItem 97 | */ 98 | public function calculatePrice(float|null $unitPrice = null) : CartItem 99 | { 100 | if (is_null($unitPrice)) { 101 | $unitPrice = $this->purchaseable->getRetailPrice(); 102 | } 103 | 104 | $this->unit_price = $unitPrice; 105 | $this->price = $unitPrice * $this->qty; 106 | 107 | return $this; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/ShoppingCartServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 20 | $this->publishes([ 21 | __DIR__.'/../database/migrations' => database_path('migrations'), 22 | ], 'migrations'); 23 | $this->publishes([ 24 | __DIR__.'/../routes' => base_path('routes'), 25 | ], 'routes'); 26 | $this->publishes([ 27 | __DIR__.'/../config/config.php' => config_path('checkout.php'), 28 | ], 'config'); 29 | $this->publishes([ 30 | __DIR__.'/Logistics' => app_path('Logistics'), 31 | ], 'logistics'); 32 | $this->publishes([ 33 | __DIR__.'/Http/Controllers/Published' => app_path('Http/Controllers/Checkout'), 34 | ], 'controllers'); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Traits/Purchaseable.php: -------------------------------------------------------------------------------- 1 | getKey() : $this->id; 15 | } 16 | 17 | /** 18 | * Determine the underlying type of this purchaseable type. 19 | * 20 | * @return string 21 | */ 22 | public function getType() : string 23 | { 24 | if (!method_exists($this, 'getMorphClass')) { 25 | return ''; 26 | } 27 | 28 | return $this->getMorphClass(); 29 | } 30 | 31 | /** 32 | * Get the retail price of this purchaseable item. 33 | * 34 | * @return float 35 | */ 36 | public function getRetailPrice() : float 37 | { 38 | return $this->price; 39 | } 40 | 41 | /** 42 | * Get the display name of this purchaseable item. 43 | * 44 | * @return string 45 | */ 46 | public function getDisplayName() : string 47 | { 48 | return $this->name; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Traits/Purchaser.php: -------------------------------------------------------------------------------- 1 | getKey() : $this->id; 15 | } 16 | 17 | /** 18 | * Determine the underlying type of this purchaseable type. 19 | * 20 | * @return string 21 | */ 22 | public function getType() : string 23 | { 24 | if (!method_exists($this, 'getMorphClass')) { 25 | return ''; 26 | } 27 | 28 | return $this->getMorphClass(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Factories/CartFactory.php: -------------------------------------------------------------------------------- 1 | define(Cart::class, function () { 8 | $address = [ 9 | 'street' => '123 Test Street', 10 | 'city' => 'Toronto', 11 | 'state_province' => 'ON', 12 | 'zip_postal' => 'L3L 3L3', 13 | 'country' => 'CA', 14 | ]; 15 | return [ 16 | 'custom_fields' => [ 17 | 'customer_info' => [ 18 | 'name' => 'John Snow', 19 | 'email' => 'johnsnow@example.net', 20 | ], 21 | 'shipping_address' => $address, 22 | 'billing_address' => $address, 23 | ], 24 | ]; 25 | }); 26 | -------------------------------------------------------------------------------- /tests/Factories/CartItemFactory.php: -------------------------------------------------------------------------------- 1 | define(CartItem::class, function () { 10 | $product = factory(Product::class)->create([ 11 | 'price' => 9.95, 12 | ]); 13 | return [ 14 | 'cart_id' => function () { 15 | return factory(Cart::class)->create()->id; 16 | }, 17 | 'purchaseable_id' => $product->id, 18 | 'purchaseable_type' => $product->getMorphClass(), 19 | 'qty' => 1, 20 | 'unit_price' => 9.95, 21 | 'price' => 9.95, 22 | ]; 23 | }); 24 | -------------------------------------------------------------------------------- /tests/Factories/Customer.php: -------------------------------------------------------------------------------- 1 | define(Customer::class, function () { 8 | return []; 9 | }); 10 | -------------------------------------------------------------------------------- /tests/Factories/NonPurchaseable.php: -------------------------------------------------------------------------------- 1 | define(NonPurchaseable::class, function () { 8 | return [ 9 | 'price' => 19.95, 10 | ]; 11 | }); 12 | -------------------------------------------------------------------------------- /tests/Factories/NonPurchaser.php: -------------------------------------------------------------------------------- 1 | define(NonPurchaser::class, function () { 8 | return []; 9 | }); 10 | -------------------------------------------------------------------------------- /tests/Factories/ProductFactory.php: -------------------------------------------------------------------------------- 1 | define(Product::class, function () { 8 | return [ 9 | 'price' => 19.95, 10 | ]; 11 | }); 12 | -------------------------------------------------------------------------------- /tests/Feature/Api/CheckoutItemTest.php: -------------------------------------------------------------------------------- 1 | create([ 19 | 'price' => 14.95, 20 | ]); 21 | 22 | $cart = factory(Cart::class)->create(); 23 | 24 | $response = $this->post(route('checkout.items.store', [ $cart->id ]), [ 25 | 'purchaseable_id' => $product->id, 26 | 'purchaseable_type' => $product->getMorphClass(), 27 | 'qty' => 1, 28 | 'options' => [ 'color' => 'green' ], 29 | ]); 30 | 31 | $response->assertSuccessful(); 32 | 33 | $this->assertDatabaseHas('cart_items', [ 34 | 'cart_id' => $cart->id, 35 | 'purchaseable_id' => $product->id, 36 | 'purchaseable_type' => $product->getMorphClass(), 37 | 'qty' => 1, 38 | 'unit_price' => 1495, 39 | 'price' => 1495, 40 | 'custom_fields' => json_encode([ 'options' => [ 'color' => 'green' ]], ) 41 | ]); 42 | } 43 | 44 | /** @test */ 45 | public function an_existing_cart_item_can_be_updated_via_the_api() 46 | { 47 | $product = factory(Product::class)->create([ 48 | 'price' => 24.95, 49 | ]); 50 | 51 | $item = factory(CartItem::class)->create([ 52 | 'purchaseable_id' => $product->id, 53 | 'purchaseable_type' => $product->getMorphClass(), 54 | 'qty' => 1, 55 | ]); 56 | $item->calculatePrice(); 57 | $item->save(); 58 | 59 | $this->assertDatabaseHas('cart_items', [ 60 | 'id' => $item->id, 61 | 'cart_id' => $item->cart_id, 62 | 'purchaseable_id' => $product->id, 63 | 'purchaseable_type' => $product->getMorphClass(), 64 | 'qty' => 1, 65 | 'unit_price' => 2495, 66 | 'price' => 2495, 67 | ]); 68 | 69 | $response = $this->put(route('checkout.items.update', [ $item->cart->id, $item->id ]), [ 70 | 'qty' => 2, 71 | ]); 72 | 73 | $response->assertSuccessful(); 74 | 75 | $this->assertDatabaseHas('cart_items', [ 76 | 'id' => $item->id, 77 | 'cart_id' => $item->cart_id, 78 | 'purchaseable_id' => $product->id, 79 | 'purchaseable_type' => $product->getMorphClass(), 80 | 'qty' => 2, 81 | 'unit_price' => 2495, 82 | 'price' => 4990, 83 | ]); 84 | } 85 | 86 | /** @test */ 87 | public function an_existing_cart_item_can_be_removed_via_the_api() 88 | { 89 | $item = factory(CartItem::class)->create(); 90 | 91 | $this->assertDatabaseHas('cart_items', [ 92 | 'id' => $item->id, 93 | ]); 94 | 95 | $response = $this->delete(route('checkout.items.destroy', [ $item->cart->id, $item->id ])); 96 | 97 | $response->assertSuccessful(); 98 | 99 | $this->assertSoftDeleted('cart_items', [ 100 | 'id' => $item->id, 101 | ]); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /tests/Feature/Api/CheckoutTest.php: -------------------------------------------------------------------------------- 1 | post(route('checkout.store')); 20 | 21 | $response->assertSuccessful(); 22 | 23 | $cart = Cart::firstOrFail(); 24 | 25 | $response->assertJson([ 26 | 'subtotal' => 0, 27 | 'cart' => [ 28 | 'id' => $cart->id, 29 | ], 30 | ]); 31 | } 32 | 33 | /** @test */ 34 | public function an_existing_checkout_can_be_retrieved_via_the_api() 35 | { 36 | $product = factory(Product::class)->create([ 37 | 'price' => 9.95, 38 | ]); 39 | 40 | $item = factory(CartItem::class)->create([ 41 | 'purchaseable_id' => $product->id, 42 | 'purchaseable_type' => $product->getMorphClass(), 43 | 'qty' => 1, 44 | 'price' => 9.95, 45 | ]); 46 | 47 | $response = $this->get(route('checkout.show', [ $item->cart->id ])); 48 | 49 | $response->assertSuccessful(); 50 | 51 | $response->assertJson([ 52 | 'subtotal' => 14.95, 53 | 'cart' => [ 54 | 'id' => $item->cart->id, 55 | 'items' => [ 56 | [ 57 | 'id' => $item->id, 58 | ], 59 | ], 60 | ], 61 | ]); 62 | } 63 | 64 | /** @test */ 65 | public function a_non_existent_checkout_returns_a_404_not_found_response() 66 | { 67 | $response = $this->get(route('checkout.show', [ 'invalid-uuid' ])); 68 | 69 | $response->assertNotFound(); 70 | 71 | $response->assertJson([ 72 | 'message' => 'The specified checkout does not exist', 73 | ]); 74 | } 75 | 76 | /** @test */ 77 | public function a_checkout_shipping_address_can_be_updated_via_the_api() 78 | { 79 | $cart = factory(Cart::class)->create(); 80 | 81 | $address = [ 82 | 'street' => '123 Test Street', 83 | 'city' => 'Toronto', 84 | 'region' => 'ON', 85 | 'postal_code' => 'L3L 3L3', 86 | ]; 87 | 88 | $response = $this->put(route('checkout.update', [ $cart->id ]), [ 89 | 'custom_fields' => [ 90 | 'customer_info' => [], 91 | 'shipping_address' => $address, 92 | 'billing_address' => [], 93 | ], 94 | ]); 95 | 96 | $response->assertSuccessful(); 97 | 98 | $this->assertDatabaseHas('carts', [ 99 | 'id' => $cart->id, 100 | 'custom_fields' => json_encode([ 101 | 'customer_info' => [], 102 | 'shipping_address' => $address, 103 | 'billing_address' => [], 104 | ]), 105 | ]); 106 | } 107 | 108 | /** @test */ 109 | public function an_existing_checkout_can_be_deleted_via_the_api() 110 | { 111 | $cart = factory(Cart::class)->create(); 112 | 113 | $response = $this->delete(route('checkout.destroy', [ $cart->id ])); 114 | 115 | $response->assertSuccessful(); 116 | 117 | $this->assertSoftDeleted('carts', [ 118 | 'id' => $cart->id, 119 | ]); 120 | } 121 | 122 | /** @test */ 123 | public function a_discount_can_be_applied_to_a_checkout_via_the_api() 124 | { 125 | $cart = factory(Cart::class)->create(); 126 | 127 | $checkout = Checkout::findById($cart->id); 128 | 129 | $product = factory(Product::class)->create([ 130 | 'price' => 10, 131 | ]); 132 | 133 | $checkout->addItem(purchaseable: $product, qty: 1); 134 | 135 | $response = $this->post(route('checkout.discount', [ $cart->id ]), [ 136 | 'code' => '50OFF' 137 | ]); 138 | 139 | $response->assertSuccessful(); 140 | 141 | $this->assertDatabaseHas('carts', [ 142 | 'id' => $cart->id, 143 | 'discount_code' => '50OFF', 144 | 'discount_amount' => round($checkout->getSubtotal() * 0.5, 2) * 100, 145 | ]); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /tests/Feature/CheckoutTest.php: -------------------------------------------------------------------------------- 1 | assertTrue($checkout instanceof Checkout); 33 | 34 | $this->assertDatabaseHas('carts', [ 35 | 'id' => $checkout->getCart()->id, 36 | ]); 37 | } 38 | 39 | /** @test */ 40 | public function a_checkout_can_be_retrieved_by_the_cart_id() 41 | { 42 | $cart = factory(Cart::class)->create(); 43 | 44 | $this->assertDatabaseHas('carts', [ 45 | 'id' => $cart->id, 46 | ]); 47 | 48 | $checkout = Checkout::findById($cart->id); 49 | 50 | $this->assertTrue($checkout instanceof Checkout); 51 | $this->assertEquals($cart->id, $checkout->getCart()->id); 52 | } 53 | 54 | /** @test */ 55 | public function a_deleted_checkout_can_be_retrieved_by_the_cart_id() 56 | { 57 | $cart = factory(Cart::class)->create(); 58 | $cart->delete(); 59 | 60 | $this->assertSoftDeleted('carts', [ 61 | 'id' => $cart->id, 62 | ]); 63 | 64 | $this->expectException(CheckoutNotFoundException::class); 65 | 66 | $checkout = Checkout::findById($cart->id); 67 | 68 | // Try again withTrashed set to true 69 | $checkout = Checkout::findById($cart->id, true); 70 | 71 | $this->assertTrue($checkout instanceof Checkout); 72 | $this->assertEquals($cart->id, $checkout->getCart()->id); 73 | } 74 | 75 | /** @test */ 76 | public function a_checkout_can_be_destroyed() 77 | { 78 | $cart = factory(Cart::class)->create(); 79 | 80 | $checkout = Checkout::findById($cart->id); 81 | $checkout->destroy(); 82 | 83 | $this->assertSoftDeleted('carts', [ 84 | 'id' => $cart->id, 85 | ]); 86 | } 87 | 88 | /** @test */ 89 | public function the_underlying_query_builder_for_a_checkout_can_be_retrieved() 90 | { 91 | $cart = factory(Cart::class)->create(); 92 | 93 | $checkout = new Checkout($cart); 94 | 95 | $this->assertTrue($checkout->getCartBuilder() instanceof Builder); 96 | 97 | $this->assertEquals(1, $checkout->getCartBuilder()->count()); 98 | } 99 | 100 | /** @test */ 101 | public function a_purchaseable_item_can_be_added_to_the_cart() 102 | { 103 | Event::fake([ 104 | CartItemAdded::class 105 | ]); 106 | 107 | $checkout = Checkout::create(); 108 | 109 | $product = factory(Product::class)->create([ 110 | 'price' => 9.95, 111 | ]); 112 | 113 | $item = $checkout->addItem($product, 1); 114 | 115 | $this->assertDatabaseHas('cart_items', [ 116 | 'cart_id' => $checkout->getCart()->id, 117 | 'purchaseable_id' => $product->id, 118 | 'purchaseable_type' => $product->getMorphClass(), 119 | 'qty' => 1, 120 | 'unit_price' => 995, 121 | 'price' => 995, 122 | ]); 123 | 124 | Event::assertDispatched(function (CartItemAdded $event) use ($item) { 125 | return $event->item->id === $item->id; 126 | }); 127 | } 128 | 129 | /** @test */ 130 | public function a_product_retail_price_can_be_manually_overriden() 131 | { 132 | $checkout = Checkout::create(); 133 | 134 | $product = factory(Product::class)->create([ 135 | 'price' => 9.95, 136 | ]); 137 | 138 | $item = $checkout->addItem($product, 1, 13.95); 139 | 140 | $this->assertDatabaseHas('cart_items', [ 141 | 'id' => $item->id, 142 | 'cart_id' => $checkout->getCart()->id, 143 | 'purchaseable_id' => $product->id, 144 | 'purchaseable_type' => $product->getMorphClass(), 145 | 'qty' => 1, 146 | 'unit_price' => 1395, 147 | 'price' => 1395, 148 | ]); 149 | } 150 | 151 | /** @test */ 152 | public function custom_options_can_be_added_to_a_checkout_item() 153 | { 154 | $checkout = Checkout::create(); 155 | 156 | $product = factory(Product::class)->create(); 157 | 158 | $item = $checkout->addItem($product, 1, null, [ 'size' => 'medium' ]); 159 | 160 | $this->assertDatabaseHas('cart_items', [ 161 | 'id' => $item->id, 162 | 'cart_id' => $checkout->getCart()->id, 163 | 'purchaseable_id' => $product->id, 164 | 'purchaseable_type' => $product->getMorphClass(), 165 | 'qty' => 1, 166 | 'custom_fields' => json_encode([ 'options' => [ 'size' => 'medium' ]]), 167 | ]); 168 | } 169 | 170 | /** @test */ 171 | public function adding_a_non_purchaseable_item_throws_an_exception() 172 | { 173 | $checkout = Checkout::create(); 174 | 175 | $product = factory(NonPurchaseable::class)->create([ 176 | 'price' => 9.95, 177 | ]); 178 | 179 | $this->expectException(ItemNotPurchaseableException::class); 180 | 181 | $checkout->addItem($product, 1); 182 | } 183 | 184 | /** @test */ 185 | public function an_existing_cart_item_can_be_updated() 186 | { 187 | Event::fake([ 188 | CartItemUpdated::class 189 | ]); 190 | 191 | $product = factory(Product::class)->create([ 192 | 'price' => 9.95, 193 | ]); 194 | 195 | $item = factory(CartItem::class)->create([ 196 | 'purchaseable_id' => $product->id, 197 | 'purchaseable_type' => $product->getMorphClass(), 198 | 'qty' => 1, 199 | ]); 200 | $item->calculatePrice(); 201 | $item->save(); 202 | 203 | $this->assertDatabaseHas('cart_items', [ 204 | 'id' => $item->id, 205 | 'purchaseable_id' => $product->id, 206 | 'purchaseable_type' => $product->getMorphClass(), 207 | 'qty' => 1, 208 | 'unit_price' => 995, 209 | 'price' => 995, 210 | ]); 211 | 212 | $checkout = Checkout::findById($item->cart->id); 213 | 214 | $item = $checkout->updateItem($item->id, 2); 215 | 216 | $this->assertEquals(2, $item->qty); 217 | $this->assertEquals(19.90, $item->price); 218 | 219 | $this->assertDatabaseHas('cart_items', [ 220 | 'id' => $item->id, 221 | 'purchaseable_id' => $product->id, 222 | 'purchaseable_type' => $product->getMorphClass(), 223 | 'qty' => 2, 224 | 'unit_price' => 995, 225 | 'price' => 1990, 226 | ]); 227 | 228 | Event::assertDispatched(function (CartItemUpdated $event) use ($item) { 229 | return $event->item->id === $item->id; 230 | }); 231 | } 232 | 233 | /** @test */ 234 | public function an_existing_cart_item_can_be_removed() 235 | { 236 | Event::fake([ 237 | CartItemDeleted::class 238 | ]); 239 | 240 | $item = factory(CartItem::class)->create(); 241 | 242 | $this->assertDatabaseHas('cart_items', [ 243 | 'id' => $item->id, 244 | ]); 245 | 246 | $checkout = Checkout::findById($item->cart->id); 247 | $checkout->removeItem($item->id); 248 | 249 | $this->assertSoftDeleted('cart_items', [ 250 | 'id' => $item->id, 251 | ]); 252 | 253 | Event::assertDispatched(function (CartItemDeleted $event) use ($item) { 254 | return $event->item->id === $item->id; 255 | }); 256 | } 257 | 258 | /** @test */ 259 | public function the_cart_shipping_costs_can_be_retrieved() 260 | { 261 | $productOne = factory(Product::class)->create([ 262 | 'price' => 100, 263 | ]); 264 | 265 | $cart = factory(Cart::class)->create(); 266 | $checkout = new Checkout($cart); 267 | 268 | $checkout->addItem($productOne, 1); 269 | 270 | $this->assertEquals(5, $checkout->getShipping()); 271 | } 272 | 273 | /** @test */ 274 | public function the_cart_subtotal_can_be_retrieved() 275 | { 276 | $productOne = factory(Product::class)->create([ 277 | 'price' => 50, 278 | ]); 279 | 280 | $productTwo = factory(Product::class)->create([ 281 | 'price' => 25, 282 | ]); 283 | 284 | $checkout = Checkout::create(); 285 | 286 | $checkout->addItem($productOne, 1); 287 | $checkout->addItem($productTwo, 2); 288 | 289 | // $100 (item cost) + 2 x $5 (shipping) 290 | $this->assertEquals(110, $checkout->getSubtotal()); 291 | } 292 | 293 | /** @test */ 294 | public function the_cart_taxes_can_be_retrieved() 295 | { 296 | $productOne = factory(Product::class)->create([ 297 | 'price' => 100, 298 | ]); 299 | 300 | $cart = factory(Cart::class)->create(); 301 | $checkout = new Checkout($cart); 302 | 303 | $checkout->addItem($productOne, 1); 304 | 305 | // $100 (item cost) + $5 (shipping) = $105 306 | // $105 x 0.18 = $18.90 307 | $this->assertEquals(18.90, $checkout->getTaxes()); 308 | } 309 | 310 | /** @test */ 311 | public function the_cart_total_can_be_retrieved() 312 | { 313 | $productOne = factory(Product::class)->create([ 314 | 'price' => 100, 315 | ]); 316 | 317 | $cart = factory(Cart::class)->create(); 318 | $checkout = new Checkout($cart); 319 | 320 | $checkout->addItem($productOne, 1); 321 | 322 | // $100 (item cost) + $5 (shipping cost) = $105 323 | // $105 x 0.18 = $18.90 324 | // $105 + $18.90 = $123.90 325 | $this->assertEquals(123.90, $checkout->getTotal()); 326 | } 327 | 328 | /** @test */ 329 | public function a_purchaser_can_be_set_for_the_checkout() 330 | { 331 | $cart = factory(Cart::class)->create(); 332 | $checkout = new Checkout($cart); 333 | 334 | $customer = factory(Customer::class)->create(); 335 | $checkout->setPurchaser($customer); 336 | 337 | $this->assertDatabaseHas('carts', [ 338 | 'id' => $cart->id, 339 | 'purchaser_id' => $customer->id, 340 | 'purchaser_type' => $customer->getMorphClass(), 341 | ]); 342 | } 343 | 344 | /** @test */ 345 | public function a_non_purchaser_cannot_be_added_to_the_checkout() 346 | { 347 | $cart = factory(Cart::class)->create(); 348 | $checkout = new Checkout($cart); 349 | 350 | $this->expectException(PurchaserInvalidException::class); 351 | 352 | $nonPurchaser = factory(NonPurchaser::class)->create(); 353 | $checkout->setPurchaser($nonPurchaser); 354 | 355 | $this->assertDatabaseMissing('carts', [ 356 | 'id' => $cart->id, 357 | 'purchaser_id' => $customer->id, 358 | 'purchaser_type' => $customer->getMorphClass(), 359 | ]); 360 | } 361 | 362 | /** @test */ 363 | public function a_discount_can_be_applied_to_the_checkout() 364 | { 365 | $productOne = factory(Product::class)->create([ 366 | 'price' => 100, 367 | ]); 368 | 369 | $cart = factory(Cart::class)->create(); 370 | $checkout = new Checkout($cart); 371 | 372 | $checkout->addItem($productOne, 1); 373 | 374 | // $100 (item cost) + $5 (shipping cost) = $105 375 | // $105 x 0.18 = $18.90 376 | // $105 + $18.90 = $123.90 377 | $this->assertEquals(123.90, $checkout->getTotal()); 378 | 379 | $checkout->applyDiscountCode('BADCODE'); // Won't work 380 | $this->assertEquals(123.90, $checkout->getTotal()); 381 | 382 | $checkout->applyDiscountCode('50OFF'); // Gives 50% off 383 | $this->assertEquals(61.95, $checkout->getTotal()); 384 | } 385 | } 386 | -------------------------------------------------------------------------------- /tests/Logistics/CartLogistics.php: -------------------------------------------------------------------------------- 1 | getSubtotal() * 0.5, 2); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Logistics/ShippingLogistics.php: -------------------------------------------------------------------------------- 1 | getCart()->items()->count(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/Logistics/TaxLogistics.php: -------------------------------------------------------------------------------- 1 | getSubtotal() - $checkout->getDiscount()) * 0.18, 2); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/Migrations/2020_12_15_000001_create_products_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->integer('price'); 19 | $table->timestamps(); 20 | $table->softDeletes(); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | * 27 | * @return void 28 | */ 29 | public function down() 30 | { 31 | Schema::dropIfExists('products'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/Migrations/2020_12_31_000001_create_customers_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->timestamps(); 19 | $table->softDeletes(); 20 | }); 21 | } 22 | 23 | /** 24 | * Reverse the migrations. 25 | * 26 | * @return void 27 | */ 28 | public function down() 29 | { 30 | Schema::dropIfExists('products'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/Models/Customer.php: -------------------------------------------------------------------------------- 1 | Money::class, 36 | ]; 37 | } 38 | -------------------------------------------------------------------------------- /tests/Models/NonPurchaser.php: -------------------------------------------------------------------------------- 1 | Money::class, 31 | ]; 32 | } 33 | -------------------------------------------------------------------------------- /tests/ShoppingCartTestProvider.php: -------------------------------------------------------------------------------- 1 | loadRoutesFrom(__DIR__.'/../routes/checkout.php'); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | loadMigrationsFrom(__DIR__ . '/../database/migrations'); 27 | $this->loadMigrationsFrom(__DIR__ . '/Migrations'); 28 | 29 | $this->withFactories(__DIR__ . '/Factories'); 30 | 31 | app()->bind(CartLogistics::class, CartLogisticsTest::class); 32 | app()->bind(TaxLogistics::class, TaxLogisticsTest::class); 33 | app()->bind(ShippingLogistics::class, ShippingLogisticsTest::class); 34 | app()->bind(DiscountLogistics::class, DiscountLogisticsTest::class); 35 | } 36 | 37 | /** 38 | * Load our custom service provider for test purposes. 39 | * 40 | * @param $app 41 | * @return array 42 | */ 43 | protected function getPackageProviders($app) 44 | { 45 | return [ 46 | 'Yab\ShoppingCart\ShoppingCartServiceProvider', 47 | 'Yab\ShoppingCart\Tests\ShoppingCartTestProvider', 48 | ]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/Unit/CartTest.php: -------------------------------------------------------------------------------- 1 | create(); 17 | 18 | $this->assertDatabaseHas('carts', [ 19 | 'id' => $cart->id 20 | ]); 21 | } 22 | 23 | /** @test */ 24 | public function a_cart_can_be_soft_deleted() 25 | { 26 | $cart = factory(Cart::class)->create(); 27 | 28 | $cart->delete(); 29 | 30 | $this->assertSoftDeleted('carts', [ 31 | 'id' => $cart->id 32 | ]); 33 | } 34 | } 35 | --------------------------------------------------------------------------------