├── .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 | [](https://packagist.org/packages/yabhq/laravel-cart)
2 | [](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 |
--------------------------------------------------------------------------------