├── .gitmodules ├── .php-cs-fixer.dist.php ├── .styleci.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── commerce.php ├── database ├── factories │ └── OrderFactory.php └── migrations │ ├── 2020_10_09_000000_create_coupons_table.php │ ├── 2020_10_09_000000_create_offers_table.php │ ├── 2020_10_09_000000_create_order_items_table.php │ ├── 2020_10_09_000000_create_orders_table.php │ ├── 2023_06_25_000000_add_product_ids_to_offers_table.php │ ├── 2023_06_29_000000_add_product_ids_to_offers_table.php │ └── 2024_03_05_000000_add_fixed_discount_currencies_to_coupons_table.php ├── routes └── web.php └── src ├── Cart.php ├── CommerceServiceProvider.php ├── Contracts ├── Gateway.php ├── Order.php └── Purchasable.php ├── Events ├── AddedToCart.php ├── CartEmptied.php ├── CouponRedeemed.php ├── OrderCompleted.php └── RemovedFromCart.php ├── Exceptions ├── CouponExpired.php ├── CouponLimitReached.php ├── CouponNotFound.php ├── OrderAlreadyComplete.php └── OrderNotAssignedToUser.php ├── Facades ├── Cart.php └── Gateway.php ├── Gateway.php ├── Gateways └── Example.php ├── Helpers ├── ExampleOffersCalculator.php ├── ExampleShippingCalculator.php └── Vat.php ├── Http └── Controllers │ ├── CartItemController.php │ ├── CheckoutController.php │ ├── Controller.php │ ├── OrderCompleteController.php │ └── WebhookController.php ├── Listeners └── IncrementCouponTimesUsed.php ├── Models ├── Coupon.php ├── Offer.php ├── Order.php └── OrderItem.php ├── Providers └── EventServiceProvider.php └── Traits ├── HandlesCartItems.php ├── HandlesCoupons.php ├── HandlesOrders.php ├── HasOrders.php ├── Purchasable.php └── SessionCart.php /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "docs"] 2 | path = docs 3 | url = https://github.com/Yiddishe-Kop/laravel-commerce-docs 4 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | true, 8 | 'array_syntax' => ['syntax' => 'short'], 9 | 'binary_operator_spaces' => [ 10 | 'default' => 'single_space', 11 | 'operators' => ['=>' => 'align_single_space'], 12 | ], 13 | 'blank_line_after_namespace' => true, 14 | 'blank_line_after_opening_tag' => true, 15 | 'blank_line_before_statement' => [ 16 | 'statements' => ['return'], 17 | ], 18 | 'braces' => true, 19 | 'cast_spaces' => true, 20 | 'class_attributes_separation' => [ 21 | 'elements' => [ 22 | 'const' => 'none', 23 | 'method' => 'one', 24 | 'property' => 'one', 25 | 'trait_import' => 'none', 26 | ], 27 | ], 28 | 'class_definition' => [ 29 | 'multi_line_extends_each_single_line' => true, 30 | 'single_item_single_line' => true, 31 | 'single_line' => true, 32 | ], 33 | 'concat_space' => [ 34 | 'spacing' => 'none', 35 | ], 36 | 'constant_case' => ['case' => 'lower'], 37 | 'declare_equal_normalize' => true, 38 | 'elseif' => true, 39 | 'encoding' => true, 40 | 'full_opening_tag' => true, 41 | 'fully_qualified_strict_types' => true, 42 | 'function_declaration' => true, 43 | 'function_typehint_space' => true, 44 | 'general_phpdoc_tag_rename' => true, 45 | 'heredoc_to_nowdoc' => true, 46 | 'include' => true, 47 | 'increment_style' => ['style' => 'post'], 48 | 'indentation_type' => true, 49 | 'linebreak_after_opening_tag' => true, 50 | 'line_ending' => true, 51 | 'lowercase_cast' => true, 52 | 'lowercase_keywords' => true, 53 | 'lowercase_static_reference' => true, 54 | 'magic_method_casing' => true, 55 | 'magic_constant_casing' => true, 56 | 'method_argument_space' => [ 57 | 'on_multiline' => 'ignore', 58 | ], 59 | 'multiline_whitespace_before_semicolons' => [ 60 | 'strategy' => 'no_multi_line', 61 | ], 62 | 'native_function_casing' => true, 63 | 'no_alias_functions' => true, 64 | 'no_extra_blank_lines' => [ 65 | 'tokens' => [ 66 | 'extra', 67 | 'throw', 68 | 'use', 69 | ], 70 | ], 71 | 'no_blank_lines_after_class_opening' => true, 72 | 'no_blank_lines_after_phpdoc' => true, 73 | 'no_closing_tag' => true, 74 | 'no_empty_phpdoc' => true, 75 | 'no_empty_statement' => true, 76 | 'no_leading_import_slash' => true, 77 | 'no_leading_namespace_whitespace' => true, 78 | 'no_mixed_echo_print' => [ 79 | 'use' => 'echo', 80 | ], 81 | 'no_multiline_whitespace_around_double_arrow' => true, 82 | 'no_short_bool_cast' => true, 83 | 'no_singleline_whitespace_before_semicolons' => true, 84 | 'no_spaces_after_function_name' => true, 85 | 'no_spaces_around_offset' => [ 86 | 'positions' => ['inside', 'outside'], 87 | ], 88 | 'no_spaces_inside_parenthesis' => true, 89 | 'no_trailing_comma_in_list_call' => true, 90 | 'no_trailing_comma_in_singleline_array' => true, 91 | 'no_trailing_whitespace' => true, 92 | 'no_trailing_whitespace_in_comment' => true, 93 | 'no_unneeded_control_parentheses' => [ 94 | 'statements' => ['break', 'clone', 'continue', 'echo_print', 'return', 'switch_case', 'yield'], 95 | ], 96 | 'no_unreachable_default_argument_value' => true, 97 | 'no_unused_imports' => true, 98 | 'no_useless_else' => true, 99 | 'no_useless_return' => true, 100 | 'no_whitespace_before_comma_in_array' => true, 101 | 'no_whitespace_in_blank_line' => true, 102 | 'normalize_index_brace' => true, 103 | 'not_operator_with_successor_space' => true, 104 | 'object_operator_without_whitespace' => true, 105 | 'ordered_imports' => ['sort_algorithm' => 'length'], 106 | 'psr_autoloading' => true, 107 | 'phpdoc_indent' => true, 108 | 'phpdoc_inline_tag_normalizer' => true, 109 | 'phpdoc_no_access' => true, 110 | 'phpdoc_no_package' => true, 111 | 'phpdoc_no_useless_inheritdoc' => true, 112 | 'phpdoc_scalar' => true, 113 | 'phpdoc_single_line_var_spacing' => true, 114 | 'phpdoc_summary' => false, 115 | 'phpdoc_to_comment' => false, 116 | 'phpdoc_tag_type' => true, 117 | 'phpdoc_trim' => true, 118 | 'phpdoc_types' => true, 119 | 'phpdoc_var_without_name' => true, 120 | 'self_accessor' => true, 121 | 'short_scalar_cast' => true, 122 | 'simplified_null_return' => false, 123 | 'single_blank_line_at_eof' => true, 124 | 'single_blank_line_before_namespace' => true, 125 | 'single_class_element_per_statement' => [ 126 | 'elements' => ['const', 'property'], 127 | ], 128 | 'single_import_per_statement' => true, 129 | 'single_line_after_imports' => true, 130 | 'single_line_comment_style' => [ 131 | 'comment_types' => ['hash'], 132 | ], 133 | 'single_quote' => true, 134 | 'space_after_semicolon' => true, 135 | 'standardize_not_equals' => true, 136 | 'switch_case_semicolon_to_colon' => true, 137 | 'switch_case_space' => true, 138 | 'ternary_operator_spaces' => true, 139 | 'trailing_comma_in_multiline' => ['elements' => ['arrays']], 140 | 'trim_array_spaces' => true, 141 | 'unary_operator_spaces' => true, 142 | 'visibility_required' => [ 143 | 'elements' => ['method', 'property'], 144 | ], 145 | 'whitespace_after_comma_in_array' => true, 146 | ]; 147 | 148 | $finder = Finder::create() 149 | ->in([ 150 | __DIR__.'/app', 151 | __DIR__.'/config', 152 | __DIR__.'/database', 153 | __DIR__.'/resources', 154 | __DIR__.'/routes', 155 | __DIR__.'/tests', 156 | ]) 157 | ->name('*.php') 158 | ->notName('*.blade.php') 159 | ->ignoreDotFiles(true) 160 | ->ignoreVCS(true); 161 | 162 | return (new Config) 163 | ->setFinder($finder) 164 | ->setRules($rules) 165 | ->setRiskyAllowed(true) 166 | ->setUsingCache(true); 167 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: laravel 2 | 3 | disabled: 4 | - single_class_element_per_statement 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### 2.2.0 (2023-06-29) 6 | 7 | - Add `active` column to `Offers` - it's now possible to deactivate Offers. The default value is `true` (for backwards compatibility). 8 | 9 | ### 2.1.0 (2023-06-25) 10 | 11 | - Add `product_ids` column to `Offers` - it's now possible to limit an Offer to specific products 12 | - Multiple Offers can now be applied on the same Order. 13 | 14 | ### 2.0.0 (2023-06-23) 15 | 16 | - Remove php7.4 support 17 | - Added events: `RemovedFromCart`, `CartEmptied` 18 | - Override the `OrderItem` model in `commerce.php` config. 19 | 20 | ### 1.0.2 (2021-11-28) 21 | 22 | - New Feature: Restrict a coupon to a specific product! 23 | 24 | We added the following columns to the `coupons` table: `product_type` & `product_id`. 25 | 26 | If you're upgrading an existing installation, create the following migration: 27 | 28 | ```php 29 | nullableMorphs('product'); 43 | }); 44 | } 45 | 46 | public function down() 47 | { 48 | Schema::table('coupons', function (Blueprint $table) { 49 | $table->dropMorphs('product'); 50 | }); 51 | } 52 | } 53 | 54 | ``` 55 | 56 | ### [1.0.1-alpha.0](https://github.com/Yiddishe-Kop/laravel-commerce/compare/v1.0.0...v1.0.1-alpha.0) (2020-11-30) 57 | 58 | ## 1.0.0 (2020-11-30) 59 | 60 | - initial release 🥳 61 | 62 | ### Features 63 | 64 | - coupons ([31f0099](https://github.com/Yiddishe-Kop/laravel-commerce/commit/31f00994bc7b386473b8257ee630918d22b01e53)) 65 | - Offer start/expiry dates ([610cfe5](https://github.com/Yiddishe-Kop/laravel-commerce/commit/610cfe519d7eafb5f5e0e19bf636467ad131de3e)) 66 | - order appends timeAgo ([2c58c51](https://github.com/Yiddishe-Kop/laravel-commerce/commit/2c58c513c457d4acb809254add1874494a239fce)) 67 | - order->timeAgo accessor ([c190a7d](https://github.com/Yiddishe-Kop/laravel-commerce/commit/c190a7d28a082f50c0c276544e9dbcc27ec7f7e3)) 68 | - OrderCompleted event ([3b6a18d](https://github.com/Yiddishe-Kop/laravel-commerce/commit/3b6a18d829df754d108db50a961b675bab5ac2d9)) 69 | - removeCoupon ([41a3681](https://github.com/Yiddishe-Kop/laravel-commerce/commit/41a3681aba7a87ce7d2f95e06cd2684fc60f50d9)) 70 | - shipping, product options ([0278a93](https://github.com/Yiddishe-Kop/laravel-commerce/commit/0278a935542fedb8f9b2943d8783db18009762c3)) 71 | - special Offers (wip) ([ba75d46](https://github.com/Yiddishe-Kop/laravel-commerce/commit/ba75d4636eec2aa0e4c6e393a628eb3c545d26aa)) 72 | 73 | ### Bug Fixes 74 | 75 | - eventServiceProvider ([fbff95e](https://github.com/Yiddishe-Kop/laravel-commerce/commit/fbff95e64a2d79781a2a54499ea7842415847d73)) 76 | - Order paid_at dates ([02a09a7](https://github.com/Yiddishe-Kop/laravel-commerce/commit/02a09a73bf1551a7e850a79b1056296706091376)) 77 | - pass attributes at cart creation ([3291bea](https://github.com/Yiddishe-Kop/laravel-commerce/commit/3291bea836ce512fa8e9461a4d537560cd6826c1)) 78 | - recalculate totals after adding/removing item from cart ([db56afd](https://github.com/Yiddishe-Kop/laravel-commerce/commit/db56afdb4f78fcea226c0e86c99da9e30f91442e)) 79 | - remove offer when removing cart-items ([f96f1a8](https://github.com/Yiddishe-Kop/laravel-commerce/commit/f96f1a814992acd412a82d407c9649522c048c7c)) 80 | - reverted to coupon_total ([fdaa6d4](https://github.com/Yiddishe-Kop/laravel-commerce/commit/fdaa6d4a30225af000d77a95c18196a0036efc8d)) 81 | - set default currency at cart creation ([c650483](https://github.com/Yiddishe-Kop/laravel-commerce/commit/c6504832c8bc4835c2c31bd9db90a213426d6dc7)) 82 | -------------------------------------------------------------------------------- /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) Yehuda Neufeld 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 | [![laravel-commerce](https://laravel-commerce.yiddishe-kop.com/preview-light.png)](https://laravel-commerce.yiddishe-kop.com/) 2 | 3 | [![Open in Visual Studio Code](https://open.vscode.dev/badges/open-in-vscode.svg)](https://open.vscode.dev/yiddishe-kop/laravel-commerce) 4 | 5 | # A simple commerce package for Laravel 6 | 7 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/yiddishe-kop/laravel-commerce.svg?style=flat-square)](https://packagist.org/packages/yiddishe-kop/laravel-commerce) 8 | [![Total Downloads](https://img.shields.io/packagist/dt/yiddishe-kop/laravel-commerce.svg?style=flat-square)](https://packagist.org/packages/yiddishe-kop/laravel-commerce) 9 | 10 | After searching for a simple ecommerce package for Laravel and not finding a lightweight simple to use solution - I decided to attempt to create one myself. 11 | 12 | Read the official documentation here: https://laravel-commerce.yiddishe-kop.com/ 13 | 14 | ### Features 15 | 16 | - [x] Cart (stored in the session - so guests can also have a cart) 17 | - [x] Orders 18 | - [x] Coupons 19 | - [x] Special Offers 20 | - [x] Multiple Currencies 21 | - [x] Multiple Payment Gateways 22 | 23 | This package only implements the backend logic, and leaves you with full control over the frontend. 24 | 25 | ## Installation 26 | 27 | You can install the package via composer: 28 | 29 | ```bash 30 | composer require yiddishe-kop/laravel-commerce 31 | ``` 32 | 33 | To publish the `commerce.php` config file: 34 | ```bash 35 | php artisan vendor:publish --provider="YiddisheKop\LaravelCommerce\CommerceServiceProvider" --tag="config" 36 | ``` 37 | 38 | You can also publish the migrations if you need to customize them: 39 | ```bash 40 | php artisan vendor:publish --provider="YiddisheKop\LaravelCommerce\CommerceServiceProvider" --tag="migrations" 41 | ``` 42 | 43 | 44 | ## Usage 45 | 46 | ### Cart 47 | 48 | You can access the cart anywhere, regardless if the user is logged in or a guest, using the facade: 49 | 50 | ``` php 51 | use YiddisheKop\LaravelCommerce\Facades\Cart; 52 | 53 | $cart = Cart::get(); 54 | ``` 55 | 56 | When the guest logs in, the cart will be attached to his account 👌. 57 | 58 | **Note**: If you want the cart to still be available after logout, you need to override the following method in `Auth\LoginController`: 59 | ```php 60 | public function logout(Request $request) { 61 | $this->guard()->logout(); 62 | 63 | // keep cart data for after logout 64 | $cartId = session()->get('cart'); 65 | $request->session()->invalidate(); 66 | $request->session()->regenerateToken(); 67 | session()->put('cart', $cartId); 68 | 69 | if ($response = $this->loggedOut($request)) { 70 | return $response; 71 | } 72 | 73 | return $request->wantsJson() 74 | ? new JsonResponse([], 204) 75 | : redirect('/'); 76 | } 77 | ``` 78 | 79 | ### Products 80 | You can make any model purchasable - by implementing the `Purchasable` contract: 81 | ```php 82 | use YiddisheKop\LaravelCommerce\Contracts\Purchasable; 83 | use YiddisheKop\LaravelCommerce\Traits\Purchasable as PurchasableTrait; 84 | 85 | class Product implements Purchasable { 86 | use PurchasableTrait; 87 | 88 | // the title of the product 89 | public function getTitle(): string { 90 | return $this->name; 91 | } 92 | 93 | // the price 94 | public function getPrice(): int { 95 | return $this->price; 96 | } 97 | } 98 | ``` 99 | 100 | ##### Add products to cart 101 | Adding a product to the cart couldn't be simpler: 102 | ```php 103 | Cart::add(Purchasable $product, int $quantity = 1); 104 | ``` 105 | Alternatively: 106 | ```php 107 | $product->addToCart($quantity = 1); 108 | ``` 109 | If you add a product that already exists in the cart, we'll automatically just update the quantity 😎 . 110 | 111 | ##### Remove products from the cart 112 | ```php 113 | Cart::remove(Purchasable $product); 114 | ``` 115 | Alternatively: 116 | ```php 117 | $product->removeFromCart(); 118 | ``` 119 | To empty the whole cart: 120 | ```php 121 | Cart::empty(); 122 | ``` 123 | #### Access cart items 124 | You can access the cart items using the `items` relation: 125 | ```php 126 | $cartItems = $cart->items; 127 | ``` 128 | To access the Product model from the cartItem, use the `model` relation (morphable): 129 | ```php 130 | $product = $cart->items[0]->model; 131 | ``` 132 | ### Calculate Totals 133 | To calculate and persist the totals of the cart, use the `calculateTotals()` method: 134 | ```php 135 | Cart::calculateTotals(); 136 | ``` 137 | Now the cart has the following data up to date: 138 | ``` 139 | [ 140 | "items_total" => 3552 141 | "tax_total" => 710.0 142 | "coupon_total" => "0" 143 | "grand_total" => 4262.0 144 | ] 145 | ``` 146 | Deleted products will automatically get removed from the cart upon calculating the totals. 147 | 148 | ## Orders 149 | You can use the `HasOrders` trait on the User model, to get a `orders` relationship: 150 | ```php 151 | 152 | use YiddisheKop\LaravelCommerce\Traits\HasOrders; 153 | 154 | class User { 155 | use HasOrders; 156 | // ... 157 | } 158 | 159 | // you can now get all the users' orders (status complete) 160 | $orders = $user->orders; 161 | ``` 162 | 163 | ### Testing 164 | This package has extensive tests - with the delightful Pest framework. To run the tests: 165 | ``` bash 166 | composer test 167 | ``` 168 | 169 | ### Changelog 170 | 171 | Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. 172 | 173 | ## Contributing 174 | 175 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 176 | 177 | ### Security 178 | 179 | If you discover any security related issues, please email yehuda@yiddishe-kop.com instead of using the issue tracker. 180 | 181 | ## Credits 182 | 183 | - [Yehuda Neufeld](https://github.com/yiddishe-kop) 184 | - [All Contributors](../../contributors) 185 | 186 | ## License 187 | 188 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 189 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yiddishe-kop/laravel-commerce", 3 | "description": "Simple commerce package for Laravel", 4 | "keywords": [ 5 | "yiddishe-kop", 6 | "laravel-commerce" 7 | ], 8 | "homepage": "https://github.com/yiddishe-kop/laravel-commerce", 9 | "license": "MIT", 10 | "type": "library", 11 | "authors": [ 12 | { 13 | "name": "Yehuda Neufeld", 14 | "email": "yehuda@yiddishe-kop.com", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.0", 20 | "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0" 21 | }, 22 | "require-dev": { 23 | "nunomaduro/collision": "^5.0|^6.1", 24 | "orchestra/testbench": "^7.7", 25 | "pestphp/pest": "^1.21", 26 | "pestphp/pest-plugin-laravel": "^1.2", 27 | "phpunit/phpunit": "^9.3.10" 28 | }, 29 | "minimum-stability": "dev", 30 | "prefer-stable": true, 31 | "autoload": { 32 | "psr-4": { 33 | "YiddisheKop\\LaravelCommerce\\": "src", 34 | "YiddisheKop\\LaravelCommerce\\Database\\Factories\\": "database/factories/" 35 | } 36 | }, 37 | "autoload-dev": { 38 | "psr-4": { 39 | "YiddisheKop\\LaravelCommerce\\Tests\\": "tests" 40 | } 41 | }, 42 | "scripts": { 43 | "a": "vendor/bin/testbench", 44 | "test": "vendor/bin/pest", 45 | "test-coverage": "vendor/bin/pest --coverage" 46 | }, 47 | "config": { 48 | "sort-packages": true, 49 | "allow-plugins": { 50 | "pestphp/pest-plugin": true 51 | } 52 | }, 53 | "extra": { 54 | "laravel": { 55 | "providers": [ 56 | "YiddisheKop\\LaravelCommerce\\CommerceServiceProvider" 57 | ], 58 | "aliases": { 59 | "Gateway": "YiddisheKop\\LaravelCommerce\\Facades\\Gateway", 60 | "Cart": "YiddisheKop\\LaravelCommerce\\Facades\\Cart" 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /config/commerce.php: -------------------------------------------------------------------------------- 1 | 'USD', 11 | 12 | // default tax rate 13 | 'tax' => [ 14 | 'rate' => 0.2, 15 | 'included_in_prices' => false, 16 | ], 17 | 18 | // Coupon settings 19 | 'coupon' => [ 20 | 'include_tax' => true, // if to apply the coupon after taxes 21 | 'include_shipping' => true, // if to apply the coupon after shipping 22 | ], 23 | 24 | /* 25 | |-------------------------------------------------------------------------- 26 | | Shipping 27 | |-------------------------------------------------------------------------- 28 | | 29 | | You can set a fixed shipping amount. If you need to calculate the cost 30 | | according to each order, just pass a class that implements a 31 | | `calculate(Order $order)` method. 32 | */ 33 | 'shipping' => [ 34 | 'calculator' => ExampleShippingCalculator::class, 35 | 'cost' => 12, // if calculator is null, this will be used 36 | ], 37 | 38 | /* 39 | |-------------------------------------------------------------------------- 40 | | Offers Calculator 41 | |-------------------------------------------------------------------------- 42 | | 43 | | You can apply discounts to order_items by creating a class that implements 44 | | an `apply(Order $order)` method. This method will get the `Order` 45 | | passed to it as a parameter. You should apply offers by setting 46 | | the `discount` on order_items. 47 | */ 48 | 'offers' => [ 49 | 'calculator' => ExampleOffersCalculator::class, 50 | ], 51 | 52 | 'models' => [ 53 | // the order model - you can replace this with your own Order model that extends this class & implements the Order contract 54 | 'order' => YiddisheKop\LaravelCommerce\Models\Order::class, 55 | 'orderItem' => YiddisheKop\LaravelCommerce\Models\OrderItem::class, 56 | // your user model - replace this with your user model 57 | 'user' => 'App\\Models\\User', 58 | ], 59 | 60 | /* 61 | |-------------------------------------------------------------------------- 62 | | Payment Gateways 63 | |-------------------------------------------------------------------------- 64 | | 65 | | You can setup multiple payment gateways for your store. 66 | | Here's where you can configure the gateways in use. 67 | */ 68 | 'gateways' => [ 69 | Example::class => [], // demo gateway 70 | ], 71 | 72 | 'prefix' => 'commerce', // routes prefix 73 | 'middleware' => ['web'], // you probably want to include 'web' here 74 | 75 | ]; 76 | -------------------------------------------------------------------------------- /database/factories/OrderFactory.php: -------------------------------------------------------------------------------- 1 | id(); 14 | $table->string('name')->default('Coupon'); 15 | $table->string('code')->unique(); 16 | $table->string('type')->default(Coupon::TYPE_PERCENTAGE); 17 | 18 | $table->integer('max_uses')->nullable(); 19 | $table->integer('times_used')->default(0); 20 | $table->integer('discount')->default(10); 21 | 22 | $table->timestamp('valid_from')->nullable(); 23 | $table->timestamp('valid_to')->nullable(); 24 | 25 | $table->nullableMorphs('product'); 26 | 27 | $table->timestamps(); 28 | }); 29 | } 30 | 31 | public function down() 32 | { 33 | Schema::dropIfExists('coupons'); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /database/migrations/2020_10_09_000000_create_offers_table.php: -------------------------------------------------------------------------------- 1 | id(); 14 | $table->string('name')->default('Special Offer'); 15 | $table->string('type')->default(Offer::TYPE_PERCENTAGE); 16 | $table->integer('min')->default(1); 17 | $table->integer('discount')->default(10); 18 | $table->string('product_type')->nullable(); 19 | 20 | $table->timestamp('valid_from')->nullable(); 21 | $table->timestamp('valid_to')->nullable(); 22 | 23 | $table->timestamps(); 24 | }); 25 | } 26 | 27 | public function down() 28 | { 29 | Schema::dropIfExists('offers'); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /database/migrations/2020_10_09_000000_create_order_items_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->foreignId('order_id'); 14 | $table->morphs('model'); 15 | $table->unsignedInteger('quantity')->default(1); 16 | $table->json('options')->nullable(); 17 | $table->string('title'); 18 | $table->integer('price'); 19 | $table->integer('discount')->default(0); 20 | }); 21 | } 22 | 23 | public function down() 24 | { 25 | Schema::dropIfExists('order_items'); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /database/migrations/2020_10_09_000000_create_orders_table.php: -------------------------------------------------------------------------------- 1 | id(); 14 | $table->foreignId('user_id')->nullable(); 15 | $table->foreignId('coupon_id')->nullable(); 16 | 17 | $table->string('status')->default(Order::STATUS_CART); 18 | $table->string('currency')->default(config('commerce.currency')); 19 | $table->timestamp('paid_at')->nullable(); 20 | 21 | $table->integer('items_total')->default(0); 22 | $table->integer('tax_total')->default(0); 23 | $table->integer('shipping_total')->default(0); 24 | $table->integer('coupon_total')->default(0); 25 | $table->integer('grand_total')->default(0); 26 | 27 | $table->string('gateway')->nullable(); 28 | $table->json('gateway_data')->nullable(); 29 | 30 | $table->timestamps(); 31 | }); 32 | } 33 | 34 | public function down() 35 | { 36 | Schema::dropIfExists('orders'); 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /database/migrations/2023_06_25_000000_add_product_ids_to_offers_table.php: -------------------------------------------------------------------------------- 1 | json('product_ids')->nullable()->after('product_type'); 13 | }); 14 | } 15 | 16 | public function down() 17 | { 18 | Schema::table('offers', function (Blueprint $table) { 19 | $table->dropColumn('product_ids'); 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /database/migrations/2023_06_29_000000_add_product_ids_to_offers_table.php: -------------------------------------------------------------------------------- 1 | boolean('active')->default(true)->after('product_ids'); 13 | }); 14 | } 15 | 16 | public function down() 17 | { 18 | Schema::table('offers', function (Blueprint $table) { 19 | $table->dropColumn('active'); 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /database/migrations/2024_03_05_000000_add_fixed_discount_currencies_to_coupons_table.php: -------------------------------------------------------------------------------- 1 | json('fixed_discount_currencies')->nullable()->after('discount'); 13 | }); 14 | } 15 | 16 | public function down() 17 | { 18 | Schema::table('coupons', function (Blueprint $table) { 19 | $table->dropColumn('fixed_discount_currencies'); 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /routes/web.php: -------------------------------------------------------------------------------- 1 | name('cart.add'); 10 | Route::delete('cart/{item}', [CartItemController::class, 'destroy'])->name('cart.remove'); 11 | 12 | Route::get('order/{order}/pay', CheckoutController::class)->name('order.pay'); 13 | Route::get('order/{order}/complete', OrderCompleteController::class)->name('order.complete'); 14 | 15 | Route::get('webhook', WebhookController::class)->name('order.webhook'); 16 | Route::post('webhook', WebhookController::class); // some gateways use POST 17 | -------------------------------------------------------------------------------- /src/Cart.php: -------------------------------------------------------------------------------- 1 | user = auth()->id(); 21 | } 22 | 23 | public function get(): OrderContract 24 | { 25 | $this->user = auth()->id(); 26 | 27 | if ($this->user) { 28 | if ($cart = config('commerce.models.order', OrderModel::class)::whereStatus(OrderModel::STATUS_CART) 29 | ->where('user_id', $this->user) 30 | ->with('items') 31 | ->first() 32 | ) { 33 | return $cart; 34 | } 35 | } 36 | 37 | return $this->getOrMakeSessionCart(); 38 | } 39 | 40 | public function find($id): OrderContract 41 | { 42 | $order = config('commerce.models.order', OrderModel::class)::isCart() 43 | ->with('items') 44 | ->find($id); 45 | 46 | if (! $order) { 47 | return $this->refreshSessionCart(); 48 | } 49 | 50 | if ($this->user && ! $order->user_id) { 51 | $order->update([ 52 | 'user_id' => $this->user, 53 | ]); 54 | } 55 | 56 | return $order; 57 | } 58 | 59 | public function create($attributes = []) 60 | { 61 | return config('commerce.models.order', OrderModel::class)::create($attributes); 62 | } 63 | 64 | public function transferGuestCartItemsToUserCart($userId) 65 | { 66 | $cartId = session()->get('cart'); 67 | $guestCart = OrderModel::isCart() 68 | ->withCount('items') 69 | ->with('items.model') 70 | ->find($cartId); 71 | $userOldCart = OrderModel::where('user_id', $userId) 72 | ->isCart() 73 | ->withCount('items') 74 | ->first(); 75 | if ($guestCart && $guestCart->items_count) { 76 | if ($userOldCart) { 77 | $guestCart->items->each(function (OrderItem $item) use ($userOldCart) { 78 | $userOldCart->add($item->model, $item->quantity, $item->options); 79 | }); 80 | $guestCart->delete(); // triggers orderItems deletion 81 | } else { 82 | $guestCart->update([ 83 | 'user_id' => $userId, 84 | ]); 85 | } 86 | } 87 | } 88 | 89 | /** 90 | * Pass dynamic method calls to the Order. 91 | */ 92 | public function __call($method, $arguments) 93 | { 94 | return $this->forwardCallTo($this->get(), $method, $arguments); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/CommerceServiceProvider.php: -------------------------------------------------------------------------------- 1 | bootVendorAssets(); 17 | $this->registerRoutes(); 18 | Gateway::bootGateways(); 19 | } 20 | 21 | protected function bootVendorAssets() 22 | { 23 | if ($this->app->runningInConsole()) { 24 | $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); 25 | 26 | $this->publishes([ 27 | __DIR__.'/../database/migrations' => $this->app->databasePath('migrations'), 28 | ], 'migrations'); 29 | 30 | $this->publishes([ 31 | __DIR__.'/../config/commerce.php' => $this->app->configPath('commerce.php'), 32 | ], 'config'); 33 | 34 | // Registering package commands. 35 | // $this->commands([]); 36 | } 37 | } 38 | 39 | protected function registerRoutes() 40 | { 41 | Route::group($this->routeConfiguration(), function () { 42 | $this->loadRoutesFrom(__DIR__.'/../routes/web.php'); 43 | }); 44 | } 45 | 46 | protected function routeConfiguration() 47 | { 48 | return [ 49 | 'prefix' => config('commerce.prefix'), 50 | 'middleware' => config('commerce.middleware'), 51 | ]; 52 | } 53 | 54 | public $bindings = [ 55 | 'gateway' => Gateway::class, 56 | 'cart' => Cart::class, 57 | ]; 58 | 59 | /** 60 | * Register the application services. 61 | */ 62 | public function register() 63 | { 64 | // Automatically apply the package configuration 65 | $this->mergeConfigFrom(__DIR__.'/../config/commerce.php', 'commerce'); 66 | 67 | $this->app->register(EventServiceProvider::class); 68 | 69 | $this->app->singleton('cart', function ($app) { 70 | return new Cart(); 71 | }); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Contracts/Gateway.php: -------------------------------------------------------------------------------- 1 | $config) { 16 | if ($class) { 17 | $class = str_replace('::class', '', $class); 18 | 19 | static::$gateways[] = [ 20 | $class, 21 | $config, 22 | ]; 23 | } 24 | } 25 | 26 | return new static(); 27 | } 28 | 29 | public static function gateways() 30 | { 31 | return collect(static::$gateways) 32 | ->map(function ($gateway) { 33 | $instance = new $gateway[0](); 34 | 35 | return [ 36 | 'name' => $instance->name(), 37 | 'handle' => Str::camel($instance->name()), 38 | 'class' => $gateway[0], 39 | 'formatted_class' => addslashes($gateway[0]), 40 | 'gateway-config' => $gateway[1], 41 | ]; 42 | }) 43 | ->toArray(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Gateways/Example.php: -------------------------------------------------------------------------------- 1 | items->first(); 12 | if (! $orderItem) { 13 | return; 14 | } 15 | $orderItem->discount = $orderItem->price / 2; // 50% discount [* qty] 16 | $orderItem->save(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Helpers/ExampleShippingCalculator.php: -------------------------------------------------------------------------------- 1 | validate([ 14 | 'product_type' => 'required|string', 15 | 'product_id' => 'required', 16 | 'quantity' => 'nullable|numeric', 17 | 'options' => 'nullable|array', 18 | ]); 19 | 20 | $product = $request->product_type::findOrFail($request->product_id); 21 | Cart::add($product, $request->quantity ?? 1, $request->options); 22 | 23 | return back()->with('success', __('Product has been added to your cart.', [ 24 | 'productTitle' => $product->getTitle(), 25 | ])); 26 | } 27 | 28 | public function destroy(int $item) 29 | { 30 | $item = config('commerce.models.orderItem', OrderItem::class)::findOrFail($item); 31 | 32 | $item->delete(); 33 | 34 | return back()->with('success', __('Product has been removed from your cart.', [ 35 | 'productTitle' => $item->title, 36 | ])); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Http/Controllers/CheckoutController.php: -------------------------------------------------------------------------------- 1 | gateway(); 16 | 17 | return $gateway->purchase($order, $request); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Http/Controllers/Controller.php: -------------------------------------------------------------------------------- 1 | gateway); 17 | Log::info($request->input()); 18 | 19 | // complete the payment 20 | /** @var \YiddisheKop\LaravelCommerce\Contracts\Gateway $gateway */ 21 | $gateway = new $order->gateway(); 22 | 23 | return $gateway->complete($order, $request); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Http/Controllers/WebhookController.php: -------------------------------------------------------------------------------- 1 | input()); 14 | 15 | $gatewayClass = "App\\Gateways\\{$request->gateway}"; 16 | /** @var \YiddisheKop\LaravelCommerce\Contracts\Gateway $gateway */ 17 | $gateway = new $gatewayClass(); 18 | 19 | return $gateway->webhook($request); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Listeners/IncrementCouponTimesUsed.php: -------------------------------------------------------------------------------- 1 | coupon->id)->increment('times_used'); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Models/Coupon.php: -------------------------------------------------------------------------------- 1 | 'datetime', 20 | 'valid_to' => 'datetime', 21 | 'fixed_discount_currencies' => 'array', 22 | ]; 23 | 24 | public function product(): MorphTo 25 | { 26 | return $this->morphTo(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Models/Offer.php: -------------------------------------------------------------------------------- 1 | 'array', 18 | 'active' => 'boolean', 19 | 'valid_from' => 'datetime', 20 | 'valid_to' => 'datetime', 21 | ]; 22 | 23 | // scopes 24 | public function scopeValid($q) 25 | { 26 | $q->where('active', true) 27 | ->where(function ($q) { 28 | $q->where('valid_from', '<', now()) 29 | ->orWhereNull('valid_from'); 30 | })->where(function ($q) { 31 | $q->where('valid_to', '>', now()) 32 | ->orWhereNull('valid_to'); 33 | }); 34 | } 35 | 36 | /** 37 | * Get first available offer for the order 38 | */ 39 | public static function getFor(Order $order): Collection 40 | { 41 | $productTypeCounts = $order->items 42 | ->groupBy('model_type') 43 | ->mapWithKeys(function ($item, $key) { 44 | return [$key => $item->sum('quantity')]; 45 | }); 46 | 47 | $validOffers = collect(); 48 | 49 | self::valid() 50 | ->orderBy('min', 'desc') 51 | ->get() 52 | ->each(function (self $offer) use ($order, $validOffers, $productTypeCounts) { 53 | 54 | $productIds = collect($offer->product_ids); 55 | if ($productIds->isNotEmpty() && ! $productIds->intersect($order->items->pluck('model_id'))->count()) { 56 | return; 57 | } 58 | 59 | if (! $offer->product_type) { // offer is valid for all product types 60 | // offer is valid for all products 61 | $validOffers->push($offer); 62 | 63 | return; 64 | } elseif ($productTypeCounts->has($offer->product_type)) { // the required product type is in the cart 65 | $amountInCart = $productTypeCounts[$offer->product_type]; 66 | if ($amountInCart >= $offer->min) { // right amount in cart 67 | $validOffers->push($offer); 68 | } 69 | } 70 | }); 71 | 72 | // highest `min` first 73 | return $validOffers->reverse(); 74 | } 75 | 76 | public function isValidFor(OrderItem $item) 77 | { 78 | $productIds = collect($this->product_ids); 79 | if ($productIds->isNotEmpty()) { 80 | if (! $productIds->contains($item->model_id)) { 81 | return false; 82 | } 83 | } 84 | 85 | if (! $this->product_type) { 86 | return true; 87 | } 88 | 89 | return $this->product_type == $item->model_type; 90 | } 91 | 92 | /** 93 | * Apply an offer on a orderItem 94 | */ 95 | public function apply(OrderItem $item) 96 | { 97 | $product = $item->model; 98 | $order = $item->order; 99 | 100 | $originalPrice = $product->getPrice($order->currency, $item->options); 101 | 102 | $discount = 0; 103 | if ($this->type == self::TYPE_FIXED) { 104 | $discount = $this->discount; 105 | } elseif ($this->type == self::TYPE_PERCENTAGE) { 106 | $discount = ($originalPrice / 100) * $this->discount; 107 | } 108 | $item->update([ 109 | 'title' => $product->getTitle(), 110 | 'price' => $originalPrice, 111 | 'discount' => $discount, 112 | ]); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Models/Order.php: -------------------------------------------------------------------------------- 1 | 'datetime', 27 | 'gateway_data' => 'array', 28 | ]; 29 | 30 | public function items(): HasMany 31 | { 32 | return $this->hasMany(config('commerce.models.orderItem', OrderItem::class), 'order_id'); 33 | } 34 | 35 | public function coupon() 36 | { 37 | return $this->belongsTo(Coupon::class); 38 | } 39 | 40 | public function user(): BelongsTo 41 | { 42 | return $this->belongsTo(config('commerce.models.user', 'App\\Models\\User')); 43 | } 44 | 45 | public function scopeIsCart($query) 46 | { 47 | return $query->where('status', self::STATUS_CART); 48 | } 49 | 50 | public function scopeCompleted($query) 51 | { 52 | return $query->where('status', self::STATUS_COMPLETED); 53 | } 54 | 55 | protected static function boot() 56 | { 57 | parent::boot(); 58 | 59 | static::deleting(function (self $cart) { 60 | return $cart->items()->delete(); 61 | }); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Models/OrderItem.php: -------------------------------------------------------------------------------- 1 | RemovedFromCart::class, 12 | 'trashed' => RemovedFromCart::class, 13 | 'forceDeleted' => RemovedFromCart::class, 14 | ]; 15 | 16 | public $timestamps = false; 17 | 18 | protected $guarded = []; 19 | 20 | protected $casts = [ 21 | 'options' => 'array', 22 | ]; 23 | 24 | public function order() 25 | { 26 | return $this->belongsTo(config('commerce.models.order', Order::class)); 27 | } 28 | 29 | public function model() 30 | { 31 | return $this->morphTo(); 32 | } 33 | 34 | public function getLineTotalAttribute() 35 | { 36 | return ($this->price - $this->discount) * $this->quantity; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Providers/EventServiceProvider.php: -------------------------------------------------------------------------------- 1 | [ 13 | IncrementCouponTimesUsed::class, 14 | ], 15 | ]; 16 | 17 | /** 18 | * Register any events for your application. 19 | * 20 | * @return void 21 | */ 22 | public function boot() 23 | { 24 | parent::boot(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Traits/HandlesCartItems.php: -------------------------------------------------------------------------------- 1 | items() 19 | ->where('model_id', $product->id) 20 | ->where('model_type', get_class($product)) 21 | ->where('options', is_null($options) ? null : json_encode($options)) 22 | ->first(); 23 | 24 | // if item is already in cart - just increment its quantity 25 | if ($existingItem && $options == $existingItem->options) { 26 | $existingItem->increment('quantity', $quantity); 27 | 28 | // update options 29 | if ($options) { 30 | $existingItem->update([ 31 | 'options' => $options, 32 | ]); 33 | } 34 | 35 | return $this; 36 | } 37 | 38 | $orderItem = $this->items()->create([ 39 | 'model_id' => $product->id, 40 | 'model_type' => get_class($product), 41 | 'title' => $product->getTitle(), 42 | 'price' => $product->getPrice($this->currency, $options), 43 | 'quantity' => $quantity, 44 | 'options' => $options, 45 | ]); 46 | 47 | event(new AddedToCart($this, $product, $orderItem)); 48 | 49 | return $this; 50 | } 51 | 52 | public function updateItem(Purchasable $product, int $quantity = 1, array $options = null): self 53 | { 54 | $existingItem = $this->items() 55 | ->where('model_id', $product->id) 56 | ->where('model_type', get_class($product)) 57 | ->first(); 58 | 59 | if ($existingItem) { 60 | $updateData = ['quantity' => $quantity]; 61 | $options && $updateData['options'] = $options; 62 | $existingItem->update($updateData); 63 | } 64 | 65 | return $this; 66 | } 67 | 68 | public function remove(Purchasable $product): self 69 | { 70 | config('commerce.models.orderItem', OrderItem::class)::where('model_id', $product->id) 71 | ->where('model_type', get_class($product)) 72 | ->delete(); 73 | 74 | return $this; 75 | } 76 | 77 | public function empty() 78 | { 79 | $this->items()->delete(); 80 | 81 | event(new CartEmptied($this)); 82 | } 83 | 84 | public function applyCoupon(string $code) 85 | { 86 | if ($coupon = Coupon::where('code', $code)->first()) { 87 | return $coupon->apply($this); 88 | } 89 | throw new CouponNotFound('Invalid coupon code', 1); 90 | } 91 | 92 | public function removeCoupon() 93 | { 94 | $this->update([ 95 | 'coupon_id' => null, 96 | ]); 97 | } 98 | 99 | private function getItemsTotal() 100 | { 101 | if ($offersCalculator = config('commerce.offers.calculator')) { 102 | $offersCalculator::apply($this); 103 | $this->refresh(); 104 | } 105 | 106 | return $this->items->fresh()->sum(fn ($item) => $item->line_total); 107 | } 108 | 109 | private function getShippingTotal() 110 | { 111 | if ($shippingCalculator = config('commerce.shipping.calculator')) { 112 | return (new $shippingCalculator())->calculate($this); 113 | } 114 | 115 | return config('commerce.shipping.cost') * 100; 116 | } 117 | 118 | private function getCouponDiscount($itemsTotal, $shippingTotal) 119 | { 120 | if (! $this->coupon) { 121 | return 0; 122 | } 123 | 124 | $couponDiscount = 0; 125 | $originalPrice = $itemsTotal; 126 | 127 | if ($this->coupon->isLimitedToProduct()) { 128 | $originalPrice = $this->items 129 | ->filter(fn ($item) => $item->model_type == $this->coupon->product_type && $item->model_id == $this->coupon->product_id) 130 | ->sum(fn ($item) => $item->line_total); 131 | if (!config('commerce.tax.included_in_prices')) { 132 | $originalPrice = Vat::add($originalPrice); // apply coupon to (price + tax) 133 | } 134 | $shippingTotal = 0; 135 | } 136 | 137 | config('commerce.coupon.include_shipping') && $originalPrice += $shippingTotal; 138 | 139 | $couponDiscount = $this->coupon->calculateDiscount($originalPrice, $this->currency); 140 | 141 | return $couponDiscount; 142 | } 143 | 144 | public function calculateTotals(bool $refreshPrices = true): self 145 | { 146 | if ($refreshPrices) { 147 | $this->refreshItems(); 148 | } 149 | 150 | $itemsTotal = $this->getItemsTotal(); 151 | $shippingTotal = $this->getShippingTotal(); 152 | 153 | if (config('commerce.coupon.include_tax')) { 154 | // calculate tax, then coupon 155 | $taxTotal = $this->calculateTax($itemsTotal, $shippingTotal); 156 | $couponDiscount = $this->getCouponDiscount($itemsTotal + $taxTotal, $shippingTotal); 157 | } else { 158 | // calculate coupon, then tax 159 | $couponDiscount = $this->getCouponDiscount($itemsTotal, $shippingTotal); 160 | $taxTotal = $this->calculateTax($itemsTotal, $shippingTotal, $couponDiscount); 161 | } 162 | 163 | // TODO: config('commerce.tax.included_in_prices') 164 | $grandTotal = ($itemsTotal + $taxTotal + $shippingTotal) - $couponDiscount; 165 | 166 | $this->update([ 167 | 'items_total' => max(0, $itemsTotal), 168 | 'coupon_total' => max(0, $couponDiscount), 169 | 'tax_total' => max(0, $taxTotal), 170 | 'shipping_total' => max(0, $shippingTotal), 171 | 'grand_total' => max(0, $grandTotal), 172 | ]); 173 | 174 | return $this; 175 | } 176 | 177 | /** 178 | * Calculate tax_total 179 | */ 180 | public function calculateTax(&$itemsTotal, &$shippingTotal, $couponDiscount = 0) 181 | { 182 | $taxableAmount = $itemsTotal - $couponDiscount; 183 | if (config('commerce.tax.included_in_prices')) { 184 | $taxTotal = Vat::of($taxableAmount); 185 | $itemsTotal -= $taxTotal; 186 | 187 | $shippingTax = Vat::of($shippingTotal); 188 | $shippingTotal -= $shippingTax; 189 | } else { 190 | $taxTotal = round($taxableAmount * config('commerce.tax.rate')); // add vat 191 | $shippingTax = round($shippingTotal * config('commerce.tax.rate')); // add vat 192 | } 193 | 194 | return $taxTotal + $shippingTax; 195 | } 196 | 197 | /** 198 | * Refresh price data from Purchasable model 199 | * Refresh coupon validity 200 | * Apply Offer 201 | * Remove deleted products from the cart 202 | * 203 | * (we can't use a constraint, as it's a morphable relationship) 204 | */ 205 | protected function refreshItems() 206 | { 207 | $cartItems = $this->items() 208 | ->with('model') 209 | ->get(); 210 | 211 | if ($this->coupon && ! $this->coupon->isValidForOrder($this)) { 212 | $this->coupon_id = null; 213 | $this->save(); 214 | } 215 | 216 | $offers = Offer::getFor($this); 217 | 218 | $cartItems->each(function (OrderItem $item) use ($offers) { 219 | if (! $item->model) { // product has been deleted 220 | return $item->delete(); // also remove from cart 221 | } 222 | if ($offers->isNotEmpty()) { 223 | $offers->each(function ($offer) use ($item) { 224 | if ($offer->isValidFor($item)) { 225 | $offer->apply($item); 226 | } 227 | }); 228 | } else { 229 | $item->update([ 230 | 'title' => $item->model->getTitle(), 231 | 'price' => $item->model->getPrice($this->currency, $item->options), 232 | 'discount' => 0, 233 | ]); 234 | } 235 | }); 236 | 237 | $this->refresh(); 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /src/Traits/HandlesCoupons.php: -------------------------------------------------------------------------------- 1 | product_type) && ! is_null($this->product_id); 23 | } 24 | 25 | /** 26 | * Check if the coupon is valid 27 | */ 28 | public function isValid() 29 | { 30 | if (($this->valid_from && $this->valid_from > now()) 31 | || ($this->valid_to && $this->valid_to < now()) 32 | ) { 33 | return false; 34 | } 35 | 36 | return true; 37 | } 38 | 39 | /** 40 | * Usage limit reached 41 | */ 42 | public function usageLimitReached(): bool 43 | { 44 | if (! is_null($this->max_uses) && $this->times_used >= $this->max_uses) { 45 | return true; 46 | } 47 | 48 | return false; 49 | } 50 | 51 | /** 52 | * Apply the coupon to an Order 53 | */ 54 | public function apply(Order $order) 55 | { 56 | if (! $this->isValid()) { 57 | throw new CouponExpired('The coupon is no longer valid', 1); 58 | } 59 | if ($this->usageLimitReached()) { 60 | throw new CouponLimitReached("The coupon has been used to it's max", 1); 61 | } 62 | if (! $this->isValidForOrder($order)) { 63 | throw new CouponNotFound('Coupon invalid for your products'); 64 | } 65 | $order->update([ 66 | 'coupon_id' => $this->id, 67 | ]); 68 | 69 | return true; 70 | } 71 | 72 | public function isValidForOrder(Order $order): bool 73 | { 74 | if (! $this->isLimitedToProduct()) { 75 | return true; 76 | } 77 | 78 | return $order->items() 79 | ->where('model_type', $this->product_type) 80 | ->where('model_id', $this->product_id) 81 | ->exists(); 82 | } 83 | 84 | /** 85 | * Calculate the amount to discount the Order 86 | */ 87 | public function calculateDiscount($originalPrice, $currency = null) 88 | { 89 | 90 | if (! $this->isValid()) { 91 | return 0; 92 | } 93 | if (! is_null($this->max_uses) && $this->times_used >= $this->max_uses) { 94 | return 0; 95 | } 96 | 97 | if ($this->type == Coupon::TYPE_FIXED) { 98 | if ($currency instanceof BackedEnum) { 99 | $currency = $currency->value; 100 | } 101 | $discount = data_get($this->fixed_discount_currencies, $currency, $this->discount); 102 | 103 | } elseif ($this->type == Coupon::TYPE_PERCENTAGE) { 104 | $discount = ($originalPrice / 100) * $this->discount; 105 | } 106 | 107 | return $discount; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/Traits/HandlesOrders.php: -------------------------------------------------------------------------------- 1 | status == Order::STATUS_COMPLETED) { 16 | throw new OrderAlreadyComplete("Can't change the currency after order has been completed", 1); 17 | } 18 | 19 | $this->update([ 20 | 'currency' => $currency, 21 | ]); 22 | 23 | $this->calculateTotals(); 24 | 25 | return $this; 26 | } 27 | 28 | /** 29 | * Mark the order as paid 30 | */ 31 | public function markAsCompleted(): self 32 | { 33 | if (! $this->user_id) { 34 | throw new OrderNotAssignedToUser('No user assigned to order', 1); 35 | } 36 | 37 | $this->update([ 38 | 'paid_at' => now(), 39 | 'status' => Order::STATUS_COMPLETED, 40 | ]); 41 | 42 | if ($this->coupon && $this->coupon_total) { 43 | event(new CouponRedeemed($this->coupon)); 44 | } 45 | 46 | event(new OrderCompleted($this)); 47 | 48 | return $this; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Traits/HasOrders.php: -------------------------------------------------------------------------------- 1 | hasMany(config('commerce.models.order', Order::class)) 13 | ->where('status', Order::STATUS_COMPLETED); 14 | } 15 | 16 | public function orderItems() 17 | { 18 | return $this->hasManyThrough(OrderItem::class, config('commerce.models.order', Order::class)) 19 | ->where('orders.status', Order::STATUS_COMPLETED); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Traits/Purchasable.php: -------------------------------------------------------------------------------- 1 | getSessionCartKey()); 19 | 20 | // attach to user if logged in 21 | if ($this->user && ! $cart->user_id) { 22 | $cart->update([ 23 | 'user_id' => $this->user, 24 | ]); 25 | } 26 | 27 | return $cart; 28 | } 29 | 30 | public function hasSessionCart(): bool 31 | { 32 | return Session::has('cart'); 33 | } 34 | 35 | protected function makeSessionCart(): Order 36 | { 37 | $cart = Cart::create([ 38 | 'user_id' => $this->user, 39 | 'currency' => config('commerce.currency'), 40 | ]); 41 | 42 | Session::put('cart', $cart->id); 43 | 44 | return $cart; 45 | } 46 | 47 | protected function getOrMakeSessionCart(): Order 48 | { 49 | if ($this->hasSessionCart()) { 50 | return $this->getSessionCart(); 51 | } 52 | 53 | return $this->makeSessionCart(); 54 | } 55 | 56 | protected function forgetSessionCart() 57 | { 58 | Session::forget('cart'); 59 | } 60 | 61 | protected function refreshSessionCart(): Order 62 | { 63 | $this->forgetSessionCart(); 64 | 65 | return $this->makeSessionCart(); 66 | } 67 | } 68 | --------------------------------------------------------------------------------