├── .php_cs ├── .phpunit.cache └── test-results ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── composer.json ├── config ├── .gitkeep └── ecommerce.php ├── database └── migrations │ ├── 0000_00_00_000000_create_cartcustomers_table.php │ ├── 0000_00_00_000000_create_cartitems_table.php │ ├── 0000_00_00_000000_create_discounts_table.php │ ├── 0000_00_00_000000_create_ecommerce_state_history_table.php │ ├── 0000_00_00_000000_create_order_items_table.php │ ├── 0000_00_00_000000_create_orders_table.php │ └── 0000_00_00_000000_create_payments_table.php └── src ├── Address ├── Address.php ├── AddressModel.php ├── AddressType.php └── StoreAddress.php ├── Cart ├── Cart.php ├── CartInterface.php ├── CartItem.php ├── CartItemBuilder.php ├── CartItemCollection.php ├── CartItemModel.php ├── CartManager.php ├── Concern │ └── InteractsWithStorage.php └── Event │ ├── CartItemAdded.php │ ├── CartItemEvent.php │ ├── CartItemRemoved.php │ └── CartItemUpdated.php ├── Contracts ├── StateInterface.php └── TransitionInterface.php ├── Currency └── CurrencyManager.php ├── Customer ├── Customer.php ├── CustomerManager.php └── CustomerModel.php ├── Discount ├── Discount.php ├── DiscountCollection.php ├── DiscountModel.php ├── DiscountTarget.php ├── DiscountType.php └── InvalidDiscountException.php ├── Facades ├── Cart.php ├── Currency.php ├── EcommerceStorage.php └── Tax.php ├── LaravelEcommerceServiceProvider.php ├── Order ├── Concern │ └── Payable.php ├── Order.php ├── OrderBuilder.php ├── OrderItem.php ├── OrderItemBuilder.php ├── OrderState.php ├── OrderTransition.php └── StateHistory.php ├── Payment ├── Callback │ ├── CreatePayment.php │ └── MarkOrderAs.php ├── Payment.php ├── PaymentState.php └── PaymentTransition.php ├── Price └── HasTotals.php ├── Purchasable.php ├── StateMachine.php ├── Storage ├── CacheStorage.php ├── EloquentStorage.php ├── SessionStorage.php ├── StorageInterface.php ├── StorageManager.php ├── StorageType.php ├── StoresDifferentInstances.php └── StoresEcommerceData.php ├── Support ├── CurrencyCast.php ├── DTOCast.php ├── InteractsWithStateMachine.php ├── InvalidTransitionException.php └── StateHistoryManager.php ├── Tax └── TaxManager.php └── helpers.php /.php_cs: -------------------------------------------------------------------------------- 1 | notPath('build/*') 5 | ->in([ 6 | __DIR__ . '/src', 7 | __DIR__ . '/tests', 8 | ]) 9 | ->name('*.php') 10 | ->notName('*.blade.php') 11 | ->ignoreDotFiles(true) 12 | ->ignoreVCS(true); 13 | 14 | return PhpCsFixer\Config::create() 15 | ->setRules([ 16 | '@PSR2' => true, 17 | 'array_indentation' => true, 18 | 'array_syntax' => ['syntax' => 'short'], 19 | 'no_extra_consecutive_blank_lines' => true, 20 | 'ordered_imports' => ['sortAlgorithm' => 'alpha'], 21 | 'no_unused_imports' => true, 22 | 'not_operator_with_successor_space' => true, 23 | 'trailing_comma_in_multiline_array' => true, 24 | 'phpdoc_scalar' => true, 25 | 'unary_operator_spaces' => true, 26 | 'binary_operator_spaces' => [ 27 | 'default' => 'align', 28 | ], 29 | 'blank_line_before_statement' => [ 30 | 'statements' => [ 31 | 'break', 32 | 'continue', 33 | 'declare', 34 | 'return', 35 | 'throw', 36 | 'try', 37 | ], 38 | ], 39 | 'phpdoc_single_line_var_spacing' => true, 40 | 'phpdoc_var_without_name' => true, 41 | 'method_argument_space' => [ 42 | 'on_multiline' => 'ensure_fully_multiline', 43 | 'keep_multiple_spaces_after_comma' => true, 44 | ], 45 | 'indentation_type' => true, 46 | 'method_chaining_indentation' => true, 47 | ]) 48 | ->setFinder($finder); 49 | -------------------------------------------------------------------------------- /.phpunit.cache/test-results: -------------------------------------------------------------------------------- 1 | {"version":1,"defects":[],"times":{"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_get_cart_manager_from_config":0.1,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_load_facade":0.012,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_get_cart_instances#session":0.164,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_get_cart_instances#cache":0.022,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_get_cart_instances#eloquent":0.209,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_generate_cart_items_ids_consistently":0.143,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_add_to_cart#session":0.01,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_add_to_cart#cache":0.011,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_add_to_cart#eloquent":0.164,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_check_if_item_is_in_cart#session":0.019,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_check_if_item_is_in_cart#cache":0.011,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_check_if_item_is_in_cart#eloquent":0.012,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_remove_from_cart#session":0.012,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_remove_from_cart#cache":0.011,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_remove_from_cart#eloquent":0.014,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_calculate_total#session":0.148,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_calculate_total#cache":0.011,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_calculate_total#eloquent":0.014,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_calculate_tax#session":0.047,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_calculate_tax#cache":0.011,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_calculate_tax#eloquent":0.015,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_calculate_item_discounts#session":0.069,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_calculate_item_discounts#cache":0.01,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_calculate_item_discounts#eloquent":0.014,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_calculate_subtotal_discounts#session":0.011,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_calculate_subtotal_discounts#cache":0.011,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_calculate_subtotal_discounts#eloquent":0.013,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_calculate_items_discounts#session":0.01,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_calculate_items_discounts#cache":0.01,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_calculate_items_discounts#eloquent":0.012,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_attach_customer_to_cart#session":0.122,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_attach_customer_to_cart#cache":0.014,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_attach_customer_to_cart#eloquent":0.022,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_add_to_cart_storing_in_db":0.014,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::user_add_to_cart_storing_in_db":0.05,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::cart_stored_storing_in_db_for_different_users":0.016,"Weble\\LaravelEcommerce\\Tests\\Cart\\CartTest::can_add_discount_to_cart_storing_in_db":0.013,"Weble\\LaravelEcommerce\\Tests\\Cart\\MultipleCartsTest::stores_different_carts_in_db":0.015,"Weble\\LaravelEcommerce\\Tests\\Cart\\CustomerTest::can_add_to_cart_storing_in_db":0.041,"Weble\\LaravelEcommerce\\Tests\\Cart\\CustomerTest::can_add_to_cart_for_user_storing_in_db":0.015,"Weble\\LaravelEcommerce\\Tests\\Cart\\CustomerTest::can_add_to_cart_for_user_storing_in_db_multiple_times":0.012,"Weble\\LaravelEcommerce\\Tests\\Order\\OrderTest::can_create_order_from_cart":0.067,"Weble\\LaravelEcommerce\\Tests\\Order\\OrderTest::order_has_unique_hash":0.022,"Weble\\LaravelEcommerce\\Tests\\Order\\OrderTest::order_items_are_stored_correctly":0.064,"Weble\\LaravelEcommerce\\Tests\\Order\\OrderTest::uses_state_machine_to_manage_order":0.155,"Weble\\LaravelEcommerce\\Tests\\Order\\OrderTest::order_history_is_stored_correctly":0.019,"Weble\\LaravelEcommerce\\Tests\\Order\\OrderTest::order_creates_payment":0.042,"Weble\\LaravelEcommerce\\Tests\\Tax\\TaxTest::can_get_tax_resolver":0.013,"Weble\\LaravelEcommerce\\Tests\\Tax\\TaxTest::can_get_tax_manager":0.01,"Weble\\LaravelEcommerce\\Tests\\Tax\\TaxTest::can_calculate_tax_for_physical_product":0.032,"Weble\\LaravelEcommerce\\Tests\\Tax\\TaxTest::can_calculate_tax_for_physical_product_with_different_country_eu_private_customer":0.01,"Weble\\LaravelEcommerce\\Tests\\Tax\\TaxTest::can_calculate_tax_for_physical_product_with_different_country_eu_company":0.01}} -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-ecommerce` will be documented in this file 4 | 5 | ## 1.0.0 - 202X-XX-XX 6 | 7 | - initial release 8 | -------------------------------------------------------------------------------- /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 world that developers are civilized and selfless people. 14 | 15 | It's the duty of the maintainer to ensure that all submissions to the project are of sufficient 16 | 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. 17 | 18 | ## Viability 19 | 20 | When requesting or submitting new features, first consider whether it might be useful to others. Open 21 | source projects are used by many developers, who may have entirely different needs to your own. Think about 22 | whether or not your feature is likely to be used by other users of the project. 23 | 24 | ## Procedure 25 | 26 | Before filing an issue: 27 | 28 | - Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. 29 | - Check to make sure your feature suggestion isn't already present within the project. 30 | - Check the pull requests tab to ensure that the bug doesn't have a fix in progress. 31 | - Check the pull requests tab to ensure that the feature isn't already in progress. 32 | 33 | Before submitting a pull request: 34 | 35 | - Check the codebase to ensure that your feature doesn't already exist. 36 | - Check the pull requests to ensure that another person hasn't already submitted the feature or fix. 37 | 38 | ## Requirements 39 | 40 | If the project maintainer has any additional requirements, you will find them listed here. 41 | 42 | - **[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). 43 | 44 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 45 | 46 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 47 | 48 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is not an option. 49 | 50 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 51 | 52 | - **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. 53 | 54 | **Happy coding**! 55 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Spatie bvba 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WARNING: STILL IN DEVELOPMENT - DO NOT USE YET 2 | 3 | # Laravel Ecommerce 4 | 5 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/weble/laravel-ecommerce.svg?style=flat-square)](https://packagist.org/packages/weble/laravel-ecommerce) 6 | [![GitHub Tests Action Status](https://img.shields.io/github/workflow/status/weble/laravel-ecommerce/run-tests?label=tests)](https://github.com/weble/laravel-ecommerce/actions?query=workflow%3Arun-tests+branch%3Amaster) 7 | [![Total Downloads](https://img.shields.io/packagist/dt/weble/laravel-ecommerce.svg?style=flat-square)](https://packagist.org/packages/weble/laravel-ecommerce) 8 | 9 | Opinionated ecommerce tools for laravel 10 | 11 | ## Introduction 12 | In a lot of projects, we encounter the same dilemma: we need to provide a customer with an "Ecommerce" website (basically, he needs to sell either a product or a service) which doesn't "fit" in the standard definition of ecommerce (ie: what you can easily build on top of the popular ecommerce CMSes, like Prestashop, Magento, Woocommerce, etc), or with the usual SaaS (Shopify, Webflow, SnipCart, etc) 13 | 14 | The natural question that arises for us is then: do we customize these CMSes to suite the particular business case of the client, or build a custom Laravel application while having to deal with all the standard (and always "expected") ecommerce features, like cart, orders, notifications, inventory, coupons, taxes, customers, etc? 15 | 16 | This is why Laravel Ecommerce was born: to provide most of these standard ecommerce features to any Laravel Application that needs them, allowing us to leverage them, without having to rebuild them from scratch every time. 17 | 18 | It is **very** opinionated, meaning we put some "assertions" in place to allow us to build upon a few "certainties". 19 | 20 | ### Prerequisites 21 | 22 | 1. Everything you sell is an Eloquent Model. 23 | 1. Everything you sell needs to be added to the cart in order to be purchased. 24 | 1. Everything you sell has a price, and the model itself is able to calculate it. 25 | 26 | ### Design Choices 27 | 28 | - In v1 we support only the Single Store model. For v2 we plan to add support for Multiple Store, with a Store Provider. 29 | - Each "thing" you add to the cart is an Eloquent Model. This is done in order to take advantage of the Polymorphic relationships, while keeping the maximum flexibility. 30 | - Everything related to "prices" is a MoneyPHP object. For this we use the excellent ```cknow/laravel-money``` wrapper for laravel 31 | - We use ```commerceguys/addressing``` to deal with addresses and zones in general 32 | - We use ```commerceguys/tax``` to deal with taxes 33 | - We use ```iben12/laravel-statable``` to deal with order management through a state machine. 34 | - We use ```barryvdh/laravel-omnipay``` to deal with payment gateways. 35 | - We provide Laravel Nova Fields / Resources / Tools as an optional way to interact with the package resources. 36 | - We provide views / assets / etc that can be used to speed up the frontend work 37 | - We provide default emails to be sent on specific events 38 | - We trigger a lot of events, to allow for maximum customization 39 | - Every class used and provided can be swapped from the configuration file. 40 | 41 | ## Installation 42 | 43 | You can install the package via composer: 44 | 45 | ```bash 46 | composer require weble/laravel-ecommerce 47 | ``` 48 | 49 | 50 | # Notes 51 | 52 | - Using more than 1 currency => publish swap config and configure it (add cache probably) 53 | - Using payments => publish omnipay config 54 | - Want nova? Install weble/laravel-ecommerce-nova 55 | - TODO: migrations (publish vs load from) 56 | - BEWARE: of changing the default currency if you don't store the currency itself in the db too 57 | 58 | ## Usage 59 | 60 | ``` php 61 | 62 | ``` 63 | 64 | ## Testing 65 | 66 | ``` bash 67 | composer test 68 | ``` 69 | 70 | ## Changelog 71 | 72 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 73 | 74 | ## Contributing 75 | 76 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 77 | 78 | ## Security 79 | 80 | If you discover any security related issues, please email daniele@weble.it instead of using the issue tracker. 81 | 82 | ## Credits 83 | 84 | - [Skullbock](https://github.com/skullbock) 85 | - [All Contributors](../../contributors) 86 | 87 | ## License 88 | 89 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 90 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "weble/laravel-ecommerce", 3 | "description": "", 4 | "keywords": [ 5 | "weble", 6 | "laravel-ecommerce", 7 | "ecommerce", 8 | "e-commerce", 9 | "laravel", 10 | "cart", 11 | "shopping-cart" 12 | ], 13 | "homepage": "https://github.com/spatie/laravel-ecommerce", 14 | "license": "MIT", 15 | "authors": [ 16 | { 17 | "name": "Daniele Rosario", 18 | "email": "daniele@weble.it", 19 | "homepage": "https://weble.it", 20 | "role": "Developer" 21 | } 22 | ], 23 | "require": { 24 | "php": "^8.1", 25 | "ext-json": "*", 26 | "cknow/laravel-money": "^6.2 || ^7.1 || ^8.0", 27 | "commerceguys/addressing": "^1.0", 28 | "commerceguys/tax": "^0.8", 29 | "florianv/laravel-swap": "^2.3", 30 | "iben12/laravel-statable": "^1.4", 31 | "illuminate/database": "^9.0 || ^10.0 || ^11.0 || ^12.0", 32 | "mpociot/vat-calculator": "^3.0", 33 | "nyholm/psr7": "^1.3", 34 | "php-http/curl-client": "^2.1", 35 | "php-http/message": "^1.9", 36 | "spatie/data-transfer-object": "^3.7" 37 | }, 38 | "require-dev": { 39 | "friendsofphp/php-cs-fixer": "^3.7", 40 | "orchestra/testbench": "^7.1 || ^8.0", 41 | "phpunit/phpunit": "^9.1 || ^10.0" 42 | }, 43 | "autoload": { 44 | "files": [ 45 | "src/helpers.php" 46 | ], 47 | "psr-4": { 48 | "Weble\\LaravelEcommerce\\": "src" 49 | } 50 | }, 51 | "autoload-dev": { 52 | "psr-4": { 53 | "Weble\\LaravelEcommerce\\Tests\\": "tests" 54 | } 55 | }, 56 | "scripts": { 57 | "test": "vendor/bin/phpunit", 58 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage", 59 | "format": "vendor/bin/php-cs-fixer fix --allow-risky=yes" 60 | }, 61 | "config": { 62 | "sort-packages": true, 63 | "allow-plugins": { 64 | "php-http/discovery": true 65 | } 66 | }, 67 | "extra": { 68 | "laravel": { 69 | "providers": [ 70 | "Weble\\LaravelEcommerce\\LaravelEcommerceServiceProvider" 71 | ], 72 | "aliases": { 73 | "LaravelEcommerce": "CartFacade" 74 | } 75 | } 76 | }, 77 | "minimum-stability": "dev", 78 | "prefer-stable": true 79 | } 80 | -------------------------------------------------------------------------------- /config/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Weble/laravel-ecommerce/e9263f178b846d04aeace1b9997dc065c9681377/config/.gitkeep -------------------------------------------------------------------------------- /config/ecommerce.php: -------------------------------------------------------------------------------- 1 | [ 6 | /* 7 | |-------------------------------------------------------------------------- 8 | | List of available currencies 9 | |-------------------------------------------------------------------------- 10 | | 11 | | We use MoneyPHP CurrencyList classes to determine the available currencies 12 | | for Laravel Ecommerce. By default all the ISO Currencies are available, 13 | | but feel free to implement your own. It needs to implement the \Money\Currencies 14 | | interface. Check http://moneyphp.org/en/stable/features/currencies.html#currencylist 15 | | for more info. 16 | */ 17 | 'currencies' => \Money\Currencies\ISOCurrencies::class, 18 | 19 | /* 20 | |-------------------------------------------------------------------------- 21 | | Default Currency Code 22 | |-------------------------------------------------------------------------- 23 | | 24 | | This is the default currency. It's what will be used to store money values 25 | | for the models where the currency is not stored alongside with it. 26 | | It's also the currency that will be used everytime you don't specify 27 | | a currency yourself when creating a money object. 28 | | Falls back to \Cknow\Money config. 29 | | Needs to be in the list of the available currencies above. 30 | */ 31 | 'default' => config('money.defaultCurrency', config('app.currency', 'USD')), 32 | 33 | /* 34 | |-------------------------------------------------------------------------- 35 | | Default User Currency 36 | |-------------------------------------------------------------------------- 37 | | 38 | | This is the default currency used to print out money values to the user when 39 | | the user itself doesn't provide an alternative 40 | */ 41 | 'user' => config('ecommerce.currency', 'USD'), 42 | 43 | /* 44 | |-------------------------------------------------------------------------- 45 | | Session Key 46 | |-------------------------------------------------------------------------- 47 | | 48 | | This is the session key used by the system to store the active user's currency 49 | */ 50 | 'session_key' => 'ecommerce.currency', 51 | ], 52 | 53 | 'customer' => [ 54 | /* 55 | |-------------------------------------------------------------------------- 56 | | Default Locale 57 | |-------------------------------------------------------------------------- 58 | | 59 | | Falls back to \Cknow\Money config. 60 | | Used for formatting prices. 61 | */ 62 | 'locale' => config('money.locale', config('app.locale', 'en_US')), 63 | 64 | /* 65 | |-------------------------------------------------------------------------- 66 | | Session Key 67 | |-------------------------------------------------------------------------- 68 | | 69 | | This is the session key used by the system to store the active customer data 70 | */ 71 | 'session_key' => 'ecommerce.customer', 72 | ], 73 | 74 | /* 75 | |-------------------------------------------------------------------------- 76 | | Storage Drivers 77 | |-------------------------------------------------------------------------- 78 | | 79 | | List of available "Storage Drivers". 80 | | 81 | | When saving "temporary data", like carts, customer informations, addresses, etc 82 | | we use one of these "stores" to persist this data in some way. 83 | | 84 | | By default we provide "session", "cache" and "eloquent" 85 | */ 86 | 87 | 'storage' => [ 88 | 89 | 'stores' => [ 90 | 91 | 'session' => [ 92 | 'prefix' => 'ecommerce.', 93 | ], 94 | 95 | 'cache' => [ 96 | // this can be any cache driver you've registered within laravel. 97 | // "default" means the default driver used for everything else 98 | 'driver' => 'default', 99 | 'prefix' => 'ecommerce.', 100 | ], 101 | 102 | 'eloquent' => [ 103 | 'fallback' => 'session', 104 | ], 105 | ], 106 | 107 | 'default' => 'session', 108 | ], 109 | 110 | /* 111 | |-------------------------------------------------------------------------- 112 | | Cart instances 113 | |-------------------------------------------------------------------------- 114 | | 115 | | List of available "Cart Instances". 116 | | By default we provide the standard "Cart", plus a secondary one that you 117 | | can use for a wishlist. 118 | | 119 | | For each instance, you can specify the storage to use in order to 120 | | persist the data. 121 | | 122 | | You can use the same driver for multiple instances 123 | */ 124 | 125 | 'cart' => [ 126 | 'instances' => [ 127 | 'cart' => [ 128 | 'storage' => 'session', 129 | ], 130 | 131 | /* 132 | 'cart' => [ 133 | 'storage' => 'eloquent', 134 | ], 135 | */ 136 | 137 | 'wishlist' => [ 138 | 'storage' => 'session', 139 | // Any other option will be passes through to the store driver 140 | 'prefix' => 'wishlist_', 141 | ], 142 | ], 143 | 144 | /* 145 | |-------------------------------------------------------------------------- 146 | | Default cart instance 147 | |-------------------------------------------------------------------------- 148 | | 149 | | When you add something to the cart, which instance gets selected by default 150 | */ 151 | 'default' => 'cart', 152 | ], 153 | 154 | /* 155 | |-------------------------------------------------------------------------- 156 | | Store 157 | |-------------------------------------------------------------------------- 158 | | 159 | | Details of the store selling the products. 160 | */ 161 | 'store' => [ 162 | 'address' => [ 163 | 'country' => 'IT', 164 | 'city' => 'Vicenza', 165 | 'zip' => '36100', 166 | 'state' => 'VI', 167 | 'address' => 'Via Enrico Fermi, 265', 168 | 'address2' => '', 169 | 'organization' => 'Weble Srl', 170 | 'vat_id' => '03579410246', 171 | ], 172 | ], 173 | 174 | /* 175 | |-------------------------------------------------------------------------- 176 | | Order 177 | |-------------------------------------------------------------------------- 178 | | 179 | | Order Settings, like hash generation and State Machine for managing the order workflow 180 | */ 181 | 'order' => [ 182 | 183 | 'hash_length' => 8, 184 | 185 | 'clear_cart' => true, 186 | 187 | /* 188 | |-------------------------------------------------------------------------- 189 | | Order Workflow 190 | |-------------------------------------------------------------------------- 191 | | 192 | | Order management workflow, as a state machine 193 | | See docs for details. 194 | */ 195 | 'workflow' => [ 196 | 'default_state' => \Weble\LaravelEcommerce\Order\OrderState::New->value(), 197 | 'class' => config('ecommerce.classes.orderModel', \Weble\LaravelEcommerce\Order\Order::class), 198 | 'graph' => 'ecommerce-order', 199 | // Name of the graph passed down to winzou/state-machine 200 | 201 | 'property_path' => 'state', 202 | // should exist on model 203 | 204 | 'states' => [ 205 | \Weble\LaravelEcommerce\Order\OrderState::New->value(), 206 | \Weble\LaravelEcommerce\Order\OrderState::Payed->value(), 207 | \Weble\LaravelEcommerce\Order\OrderState::Canceled->value(), 208 | \Weble\LaravelEcommerce\Order\OrderState::Refunded->value(), 209 | \Weble\LaravelEcommerce\Order\OrderState::Completed->value(), 210 | ], 211 | 'transitions' => [ 212 | \Weble\LaravelEcommerce\Order\OrderTransition::Pay->value => [ 213 | 'from' => [ 214 | \Weble\LaravelEcommerce\Order\OrderState::New->value(), 215 | ], 216 | 'to' => \Weble\LaravelEcommerce\Order\OrderState::Payed->value(), 217 | ], 218 | 219 | \Weble\LaravelEcommerce\Order\OrderTransition::Cancel->value => [ 220 | 'from' => [ 221 | \Weble\LaravelEcommerce\Order\OrderState::New->value(), 222 | ], 223 | 'to' => \Weble\LaravelEcommerce\Order\OrderState::Canceled->value(), 224 | 'metadata' => [ 225 | 'title' => 'Cancel', 226 | 'classes' => 'btn btn-default btn-danger', 227 | ], 228 | ], 229 | 230 | \Weble\LaravelEcommerce\Order\OrderTransition::Refund->value => [ 231 | 'from' => [ 232 | \Weble\LaravelEcommerce\Order\OrderState::Payed->value(), 233 | \Weble\LaravelEcommerce\Order\OrderState::Completed->value(), 234 | ], 235 | 'to' => \Weble\LaravelEcommerce\Order\OrderState::Refunded->value(), 236 | ], 237 | 238 | \Weble\LaravelEcommerce\Order\OrderTransition::Complete->value => [ 239 | 'from' => [ 240 | \Weble\LaravelEcommerce\Order\OrderState::Payed->value(), 241 | ], 242 | 'to' => \Weble\LaravelEcommerce\Order\OrderState::Completed->value(), 243 | ], 244 | ], 245 | 'callbacks' => [ 246 | 'after' => [ 247 | 'history' => [ 248 | 'do' => new Weble\LaravelEcommerce\Support\StateHistoryManager, 249 | ], 250 | ], 251 | ], 252 | ], 253 | ], 254 | 255 | /* 256 | |-------------------------------------------------------------------------- 257 | | Payment 258 | |-------------------------------------------------------------------------- 259 | | 260 | | Payment settings, like the State Machine for managing the payment workflow 261 | */ 262 | 'payment' => [ 263 | 264 | /* 265 | |-------------------------------------------------------------------------- 266 | | Payment Workflow 267 | |-------------------------------------------------------------------------- 268 | | 269 | | Payment processing workflow, as a state machine 270 | | See docs for details. 271 | */ 272 | 'workflow' => [ 273 | 'class' => config('ecommerce.classes.paymentModel', \Weble\LaravelEcommerce\Payment\Payment::class), 274 | 'graph' => 'ecommerce-payment', 275 | // Name of the graph passed down to winzou/state-machine 276 | 277 | 'property_path' => 'state', 278 | // should exist on model 279 | 280 | 'states' => [ 281 | \Weble\LaravelEcommerce\Payment\PaymentState::Created->value(), 282 | \Weble\LaravelEcommerce\Payment\PaymentState::Processing->value(), 283 | \Weble\LaravelEcommerce\Payment\PaymentState::Completed->value(), 284 | \Weble\LaravelEcommerce\Payment\PaymentState::Failed->value(), 285 | \Weble\LaravelEcommerce\Payment\PaymentState::Canceled->value(), 286 | \Weble\LaravelEcommerce\Payment\PaymentState::Refunded->value(), 287 | ], 288 | 'transitions' => [ 289 | \Weble\LaravelEcommerce\Payment\PaymentTransition::Process->value => [ 290 | 'from' => [ 291 | \Weble\LaravelEcommerce\Payment\PaymentState::Created->value(), 292 | ], 293 | 'to' => \Weble\LaravelEcommerce\Payment\PaymentState::Processing->value(), 294 | ], 295 | \Weble\LaravelEcommerce\Payment\PaymentTransition::Complete->value => [ 296 | 'from' => [ 297 | \Weble\LaravelEcommerce\Payment\PaymentState::Created->value(), 298 | \Weble\LaravelEcommerce\Payment\PaymentState::Processing->value(), 299 | ], 300 | 'to' => \Weble\LaravelEcommerce\Payment\PaymentState::Completed->value(), 301 | ], 302 | \Weble\LaravelEcommerce\Payment\PaymentTransition::Fail->value => [ 303 | 'from' => [ 304 | \Weble\LaravelEcommerce\Payment\PaymentState::Created->value(), 305 | \Weble\LaravelEcommerce\Payment\PaymentState::Processing->value(), 306 | ], 307 | 'to' => \Weble\LaravelEcommerce\Payment\PaymentState::Failed->value(), 308 | 'metadata' => [ 309 | 'title' => 'Fail', 310 | 'classes' => 'btn btn-default btn-danger', 311 | ], 312 | ], 313 | \Weble\LaravelEcommerce\Payment\PaymentTransition::Cancel->value => [ 314 | 'from' => [ 315 | \Weble\LaravelEcommerce\Payment\PaymentState::Created->value(), 316 | \Weble\LaravelEcommerce\Payment\PaymentState::Processing->value(), 317 | \Weble\LaravelEcommerce\Payment\PaymentState::Failed->value(), 318 | ], 319 | 'to' => \Weble\LaravelEcommerce\Payment\PaymentState::Canceled->value(), 320 | ], 321 | \Weble\LaravelEcommerce\Payment\PaymentTransition::Refund->value => [ 322 | 'from' => [ 323 | \Weble\LaravelEcommerce\Payment\PaymentState::Completed->value(), 324 | ], 325 | 'to' => \Weble\LaravelEcommerce\Payment\PaymentState::Refunded->value(), 326 | ], 327 | ], 328 | 'callbacks' => [ 329 | 'after' => [ 330 | 'on-completed' => [ 331 | 'to' => \Weble\LaravelEcommerce\Payment\PaymentState::Completed->value(), 332 | 'do' => new \Weble\LaravelEcommerce\Payment\Callback\MarkOrderAs(\Weble\LaravelEcommerce\Order\OrderTransition::Pay), 333 | ], 334 | 'on-refunded' => [ 335 | 'to' => \Weble\LaravelEcommerce\Payment\PaymentState::Refunded->value(), 336 | 'do' => new \Weble\LaravelEcommerce\Payment\Callback\MarkOrderAs(\Weble\LaravelEcommerce\Order\OrderTransition::Refund), 337 | ], 338 | 'history' => [ 339 | 'do' => new Weble\LaravelEcommerce\Support\StateHistoryManager, 340 | ], 341 | ], 342 | ], 343 | ], 344 | ], 345 | 346 | /* 347 | |-------------------------------------------------------------------------- 348 | | Taxes 349 | |-------------------------------------------------------------------------- 350 | | 351 | | Tax settings 352 | */ 353 | 354 | 'tax' => [ 355 | /* 356 | |-------------------------------------------------------------------------- 357 | | Address to Use for Taxes 358 | |-------------------------------------------------------------------------- 359 | | 360 | | Which address should be used when calculating taxes. "shipping" or "billing" 361 | */ 362 | 'address_type' => \Weble\LaravelEcommerce\Address\AddressType::Shipping, 363 | 364 | /* 365 | |-------------------------------------------------------------------------- 366 | | Check Valid VAT ID 367 | |-------------------------------------------------------------------------- 368 | | 369 | | In case of EU VAT, should the VAT ID be checked realtime to consider the company valid? 370 | | Defaults to true. 371 | */ 372 | 'vat_id_check' => true, 373 | ], 374 | 375 | /* 376 | |-------------------------------------------------------------------------- 377 | | Coupon 378 | |-------------------------------------------------------------------------- 379 | | 380 | | Coupon table name 381 | */ 382 | 'coupon' => [ 383 | 'table' => 'coupons', 384 | ], 385 | 386 | /* 387 | |-------------------------------------------------------------------------- 388 | | Configurable Classes 389 | |-------------------------------------------------------------------------- 390 | | 391 | | You can swap our classes with yours here 392 | */ 393 | 'classes' => [ 394 | 'user' => \App\Models\User::class, 395 | 'stateMachine' => \Weble\LaravelEcommerce\StateMachine::class, 396 | 'storageManager' => \Weble\LaravelEcommerce\Storage\StorageManager::class, 397 | 'currencyManager' => \Weble\LaravelEcommerce\Currency\CurrencyManager::class, 398 | 'taxManager' => \Weble\LaravelEcommerce\Tax\TaxManager::class, 399 | 'cartManager' => \Weble\LaravelEcommerce\Cart\CartManager::class, 400 | 'cartItemModel' => \Weble\LaravelEcommerce\Cart\CartItemModel::class, 401 | 'cart' => \Weble\LaravelEcommerce\Cart\Cart::class, 402 | 'orderModel' => \Weble\LaravelEcommerce\Order\Order::class, 403 | 'orderItemModel' => \Weble\LaravelEcommerce\Order\OrderItem::class, 404 | 'orderHistoryModel' => \Weble\LaravelEcommerce\Order\StateHistory::class, 405 | 'orderBuilder' => \Weble\LaravelEcommerce\Order\OrderBuilder::class, 406 | 'orderItemBuilder' => \Weble\LaravelEcommerce\Order\OrderItemBuilder::class, 407 | 'paymentModel' => \Weble\LaravelEcommerce\Payment\Payment::class, 408 | 'customerModel' => \Weble\LaravelEcommerce\Customer\CustomerModel::class, 409 | 'addressModel' => \Weble\LaravelEcommerce\Address\AddressModel::class, 410 | 'discountModel' => \Weble\LaravelEcommerce\Discount\DiscountModel::class, 411 | ], 412 | 413 | /* 414 | |-------------------------------------------------------------------------- 415 | | Configurable Table names 416 | |-------------------------------------------------------------------------- 417 | | 418 | | When using the eloquent storage, by default we'll use these table names 419 | */ 420 | 'tables' => [ 421 | 'items' => 'cart_items', 422 | 'customers' => 'cart_customers', 423 | 'discounts' => 'cart_discounts', 424 | 'orders' => 'orders', 425 | 'order_items' => 'order_items', 426 | 'state_history' => 'ecommerce_state_history', 427 | 'payments' => 'payments', 428 | ], 429 | ]; 430 | -------------------------------------------------------------------------------- /database/migrations/0000_00_00_000000_create_cartcustomers_table.php: -------------------------------------------------------------------------------- 1 | char('id', 40)->primary(); 16 | $table->char('session_id')->nullable()->index(); // Max length for a session_id 17 | $table->foreignIdFor(config('ecommerce.classes.user', '\\App\\Models\\User'))->nullable(); 18 | $table->json('billing_address'); 19 | $table->json('shipping_address'); 20 | $table->timestamps(); 21 | 22 | $table->index(['user_id']); 23 | }); 24 | } 25 | /** 26 | * Reverse the migrations. 27 | */ 28 | public function down() 29 | { 30 | Schema::drop(config('ecommerce.tables.customers', 'cart_customers')); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /database/migrations/0000_00_00_000000_create_cartitems_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->char('cart_item_id', 40)->index(); // Sha1 17 | $table->char('session_id')->nullable()->index(); // Max length for a session_id 18 | $table->foreignIdFor(config('ecommerce.classes.user', '\\App\\Models\\User'))->nullable(); 19 | $table->string('instance')->index(); 20 | $table->morphs('purchasable'); 21 | $table->bigInteger('price'); 22 | $table->float('quantity')->default(1); 23 | $table->json('product_attributes'); 24 | $table->json('discounts'); 25 | $table->timestamps(); 26 | 27 | $table->index(['cart_item_id', 'instance', 'session_id']); 28 | $table->index(['instance', 'user_id']); 29 | $table->index(['instance', 'session_id']); 30 | }); 31 | } 32 | /** 33 | * Reverse the migrations. 34 | */ 35 | public function down() 36 | { 37 | Schema::drop(config('ecommerce.tables.items', 'cart_items')); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /database/migrations/0000_00_00_000000_create_discounts_table.php: -------------------------------------------------------------------------------- 1 | id(); 17 | $table->char('discount_id', 40)->index(); // Sha1 18 | $table->char('session_id')->nullable()->index(); // Max length for a session_id 19 | $table->foreignIdFor(config('ecommerce.classes.user', '\\App\\Models\\User'))->nullable(); 20 | $table->string('instance')->index(); 21 | $table->string('type'); 22 | $table->string('target'); 23 | $table->bigInteger('value')->default(0); 24 | $table->json('discount_attributes'); 25 | $table->char('currency', 3)->nullable(); 26 | $table->timestamps(); 27 | }); 28 | } 29 | /** 30 | * Reverse the migrations. 31 | */ 32 | public function down() 33 | { 34 | Schema::drop(config('ecommerce.tables.discounts', 'cart_discounts')); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /database/migrations/0000_00_00_000000_create_ecommerce_state_history_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->morphs('model'); 17 | 18 | $table->string('transition'); 19 | $table->string('from'); 20 | $table->string('to'); 21 | $table->integer('actor_id')->nullable(); 22 | 23 | $table->timestamps(); 24 | 25 | $table->index(['actor_id']); 26 | }); 27 | } 28 | /** 29 | * Reverse the migrations. 30 | */ 31 | public function down() 32 | { 33 | Schema::drop(config('ecommerce.tables.state_history', 'ecommerce_state_history')); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /database/migrations/0000_00_00_000000_create_order_items_table.php: -------------------------------------------------------------------------------- 1 | id(); 17 | $table->foreignIdFor(Order::class); 18 | 19 | $table->morphs('purchasable'); 20 | $table->json('purchasable_data'); 21 | $table->float('quantity')->default(1); 22 | $table->json('product_attributes'); 23 | $table->json('discounts'); 24 | 25 | $table->bigInteger('unit_price'); 26 | $table->bigInteger('discounts_subtotal'); 27 | $table->bigInteger('subtotal'); 28 | 29 | $table->timestamps(); 30 | }); 31 | } 32 | /** 33 | * Reverse the migrations. 34 | */ 35 | public function down() 36 | { 37 | Schema::drop(config('ecommerce.tables.order_items', 'order_items')); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /database/migrations/0000_00_00_000000_create_orders_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->string('hash')->unique(); 19 | $table->foreignUuid('cart_id')->nullable(); 20 | $table->char('customer_id', 40)->nullable(); 21 | $table->foreignIdFor(config('ecommerce.classes.user', \App\Models\User::class))->nullable(); 22 | $table->json('customer')->nullable(); 23 | $table->char('currency', 3)->default(config('ecommerce.currency.default', 'USD')); 24 | $table->string('state')->nullable(); 25 | $table->string('payment_gateway')->nullable(); 26 | $table->json('discounts'); 27 | $table->bigInteger('discounts_subtotal')->default(0); 28 | $table->bigInteger('paid')->default(0); 29 | $table->bigInteger('items_subtotal')->default(0); 30 | $table->bigInteger('items_total')->default(0); 31 | $table->bigInteger('shipping_subtotal')->default(0); 32 | $table->bigInteger('shipping_total')->default(0); 33 | $table->bigInteger('subtotal')->default(0); 34 | $table->bigInteger('tax')->default(0); 35 | $table->bigInteger('total')->default(0); 36 | $table->string('tracking_code')->nullable(); 37 | $table->float('exchange_rate')->default(1); 38 | $table->string('invoice_number')->nullable(); 39 | $table->timestamp('invoice_date')->nullable(); 40 | $table->timestamp('delivery_date')->nullable(); 41 | $table->timestamps(); 42 | }); 43 | } 44 | 45 | /** 46 | * Reverse the migrations. 47 | */ 48 | public function down() 49 | { 50 | Schema::drop(config('ecommerce.tables.orders', 'orders')); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /database/migrations/0000_00_00_000000_create_payments_table.php: -------------------------------------------------------------------------------- 1 | id(); 17 | $table->foreignIdFor(Order::class); 18 | $table->char('currency', 3)->default(config('ecommerce.currency.default', 'USD')); 19 | $table->string('state')->nullable(); 20 | $table->string('payment_gateway')->nullable(); 21 | $table->string('transaction_id')->nullable(); 22 | $table->bigInteger('total')->default(0); 23 | $table->timestamps(); 24 | }); 25 | } 26 | /** 27 | * Reverse the migrations. 28 | */ 29 | public function down() 30 | { 31 | Schema::drop(config('ecommerce.tables.payments', 'payments')); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Address/Address.php: -------------------------------------------------------------------------------- 1 | make(CountryRepositoryInterface::class)->get($this->country); 43 | } 44 | 45 | public function getCountryCode() 46 | { 47 | return $this->country; 48 | } 49 | 50 | public function getAdministrativeArea() 51 | { 52 | return $this->state; 53 | } 54 | 55 | public function getLocality() 56 | { 57 | return $this->city; 58 | } 59 | 60 | public function getDependentLocality() 61 | { 62 | return ''; 63 | } 64 | 65 | public function getPostalCode() 66 | { 67 | return $this->zip; 68 | } 69 | 70 | public function getSortingCode() 71 | { 72 | return $this->zip; 73 | } 74 | 75 | public function getAddressLine1() 76 | { 77 | return $this->street; 78 | } 79 | 80 | public function getAddressLine2() 81 | { 82 | return ''; 83 | } 84 | 85 | public function getOrganization() 86 | { 87 | return $this->company; 88 | } 89 | 90 | public function getGivenName() 91 | { 92 | return $this->name; 93 | } 94 | 95 | public function getAdditionalName() 96 | { 97 | return ''; 98 | } 99 | 100 | public function getFamilyName() 101 | { 102 | return $this->surname; 103 | } 104 | 105 | public function getLocale() 106 | { 107 | return app()->getLocale(); 108 | } 109 | 110 | public function getVatId(): string 111 | { 112 | return $this->vatId; 113 | } 114 | 115 | public function getPersonalId(): string 116 | { 117 | return $this->personalId; 118 | } 119 | 120 | public static function fromRequest(Request $request, $type = 'shipping'): self 121 | { 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Address/AddressModel.php: -------------------------------------------------------------------------------- 1 | setTable(config('ecommerce.customer.table', 'coupons')); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Address/AddressType.php: -------------------------------------------------------------------------------- 1 | vatId = config('ecommerce.store.address.vat_id', ''); 27 | } 28 | 29 | public function withVatId(string $string): self 30 | { 31 | $new = clone $this; 32 | $new->vatId = $string; 33 | 34 | return $new; 35 | } 36 | 37 | public function vatId(): string 38 | { 39 | return $this->vatId(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Cart/Cart.php: -------------------------------------------------------------------------------- 1 | instanceName = $instanceName; 33 | 34 | $this->storage = $storage; 35 | $this->storage()->setInstanceName($instanceName); 36 | $this->loadFromStorage(); 37 | } 38 | 39 | public function instanceName(): string 40 | { 41 | return $this->instanceName; 42 | } 43 | 44 | public function items(): CartItemCollection 45 | { 46 | return $this->items; 47 | } 48 | 49 | public function discounts(): DiscountCollection 50 | { 51 | return $this->discounts->merge($this->items()->map(function (CartItem $cartItem) { 52 | return $cartItem->discounts; 53 | })->flatten()); 54 | } 55 | 56 | public function customer(): Customer 57 | { 58 | return $this->customer; 59 | } 60 | 61 | public function get(string $id): CartItem 62 | { 63 | return $this->items()->get($id); 64 | } 65 | 66 | public function has(string $id): bool 67 | { 68 | return $this->storage()->get(StorageType::Items->value, collect([]))->has($id); 69 | } 70 | 71 | public function clear(): self 72 | { 73 | $this->storage()->remove(StorageType::Items->value); 74 | $this->storage()->remove(StorageType::Discounts->value); 75 | 76 | $this->items = CartItemCollection::make([]); 77 | $this->discounts = DiscountCollection::make([]); 78 | 79 | return $this; 80 | } 81 | 82 | public function add(Purchasable $purchasable, float $quantity = 1, ?Collection $attributes = null, ?Money $price = null): CartItem 83 | { 84 | if ($attributes === null) { 85 | $attributes = collect([]); 86 | } 87 | 88 | $cartItem = CartItem::fromPurchasable($purchasable, $quantity, $attributes); 89 | if ($price !== null) { 90 | $cartItem->price = $price; 91 | } 92 | 93 | if ($this->items()->has($cartItem->getId())) { 94 | $cartItem->quantity += $this->items()->get($cartItem->getId())->quantity; 95 | } 96 | 97 | $this->items()->put($cartItem->getId(), $cartItem); 98 | $this->persist(StorageType::Items->value, $this->items()); 99 | 100 | event(new CartItemAdded($cartItem, $this->instanceName())); 101 | 102 | return $cartItem; 103 | } 104 | 105 | public function update(CartItem $cartItem): self 106 | { 107 | if (! $this->items()->has($cartItem->getId())) { 108 | return $this; 109 | } 110 | 111 | $this->items()->put($cartItem->getId(), $cartItem); 112 | $this->persist(StorageType::Items->value, $this->items()); 113 | 114 | event(new CartItemUpdated($cartItem, $this->instanceName())); 115 | 116 | return $this; 117 | } 118 | 119 | public function remove(CartItem $cartItem): self 120 | { 121 | if (! $this->items()->has($cartItem->getId())) { 122 | return $this; 123 | } 124 | 125 | $this->items = $this->items()->except($cartItem->getId()); 126 | $this->persist(StorageType::Items->value, $this->items()); 127 | 128 | event(new CartItemRemoved($cartItem, $this->instanceName())); 129 | 130 | return $this; 131 | } 132 | 133 | public function forCustomer(Customer $customer): self 134 | { 135 | $this->customer = $customer; 136 | 137 | return $this->persist(StorageType::Customer->value, $this->customer()); 138 | } 139 | 140 | public function withDiscount(Discount $discount): self 141 | { 142 | if ($discount->target === DiscountTarget::Item) { 143 | throw new InvalidDiscountException(); 144 | } 145 | 146 | $this->discounts->add($discount); 147 | 148 | return $this->persist(StorageType::Discounts->value, $this->discounts()); 149 | } 150 | 151 | public function removeDiscounts($keys): self 152 | { 153 | $this->discounts = $this->discounts->except($keys); 154 | $this->persist(StorageType::Discounts->value, $this->discounts()); 155 | 156 | return $this; 157 | } 158 | 159 | public function clearDiscounts(): self 160 | { 161 | $this->discounts = new DiscountCollection([]); 162 | $this->items()->each(function(CartItem $item) { 163 | $item->clearDiscounts(); 164 | }); 165 | 166 | $this->persist(StorageType::Discounts->value, $this->discounts()); 167 | $this->persist(StorageType::Items->value, $this->items()); 168 | 169 | return $this; 170 | } 171 | 172 | public function discount(): Money 173 | { 174 | return Money::sum( 175 | $this->discounts->withTarget(DiscountTarget::Items)->total($this->itemsSubtotal()), 176 | $this->discounts->withTarget(DiscountTarget::Subtotal)->total($this->subTotalWithoutDiscounts()) 177 | ); 178 | } 179 | 180 | public function singleItemsDiscounts(): Money 181 | { 182 | return $this->items()->reduce(function (Money $carry, CartItem $item) { 183 | return $carry->add($item->discount()); 184 | }, new Money(0, currencyManager()->defaultCurrency())); 185 | } 186 | 187 | public function subTotalWithoutDiscounts(): Money 188 | { 189 | return $this->itemsSubtotal(); 190 | } 191 | 192 | public function itemsSubtotal(): Money 193 | { 194 | if ($this->items()->count() <= 0) { 195 | return new Money(0, currencyManager()->defaultCurrency()); 196 | } 197 | 198 | return $this->items()->reduce(function (?Money $sum = null, ?CartItem $cartItem = null) { 199 | if ($sum === null) { 200 | return $cartItem->subTotal(); 201 | } 202 | 203 | return $sum->add($cartItem->subTotal()); 204 | }); 205 | } 206 | 207 | public function subTotal(): Money 208 | { 209 | return $this->subTotalWithoutDiscounts()->subtract($this->discount()); 210 | } 211 | 212 | public function tax(): Money 213 | { 214 | if ($this->items()->count() <= 0) { 215 | return new Money(0, currencyManager()->defaultCurrency()); 216 | } 217 | 218 | $tax = $this->items()->reduce(function (?Money $sum = null, ?CartItem $cartItem = null) { 219 | if ($sum === null) { 220 | return $cartItem->tax($this->customer->taxAddress()); 221 | } 222 | 223 | return $sum->add($cartItem->tax($this->customer->taxAddress())); 224 | }); 225 | 226 | // discounts is just a collection of "subtotal" and "items" discount target types 227 | if ($this->discounts->count() > 0) { 228 | // proportionate discount also on tax 229 | return $tax 230 | ->multiply($this->subTotal()->getAmount()) 231 | ->divide($this->subTotalWithoutDiscounts()->getAmount()); 232 | } 233 | 234 | return $tax; 235 | } 236 | 237 | public function total(): Money 238 | { 239 | return $this->subTotal()->add($this->tax()); 240 | } 241 | 242 | public function toArray() 243 | { 244 | return [ 245 | 'instance' => $this->instanceName(), 246 | 'items' => $this->items()->toArray(), 247 | 'discounts' => $this->discounts()->toArray(), 248 | 'subtotal' => $this->subTotal()->toArray(), 249 | 'tax' => $this->tax()->toArray(), 250 | 'total' => $this->total()->toArray(), 251 | ]; 252 | } 253 | 254 | public function toJson($options = 0) 255 | { 256 | return json_encode($this->toArray(), $options); 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /src/Cart/CartInterface.php: -------------------------------------------------------------------------------- 1 | target !== DiscountTarget::Item) { 29 | throw new InvalidDiscountException(); 30 | } 31 | 32 | $this->discounts->add($discount); 33 | 34 | return $this; 35 | } 36 | 37 | public function clearDiscounts(): self 38 | { 39 | $this->discounts = DiscountCollection::make([]); 40 | 41 | return $this; 42 | } 43 | 44 | public function toJson($options = 0): string 45 | { 46 | return json_encode($this->toArray(), $options); 47 | } 48 | 49 | public static function fromPurchasable(Purchasable $purchasable, float $quantity = 1, ?Collection $attributes = null, ?Money $price = null): self 50 | { 51 | if ($attributes === null) { 52 | $attributes = collect([]); 53 | } 54 | 55 | $price ??= $purchasable->cartPrice($attributes); 56 | 57 | return new static([ 58 | 'product' => $purchasable, 59 | 'attributes' => $attributes, 60 | 'quantity' => $quantity, 61 | 'price' => $price, 62 | 'discounts' => DiscountCollection::make([]), 63 | ]); 64 | } 65 | 66 | public function getId(): string 67 | { 68 | return sha1(implode("-", [ 69 | $this->morphAlias($this->product), 70 | $this->product->getKey(), 71 | $this->attributes->toJson() 72 | ])); 73 | } 74 | 75 | private function morphAlias(Purchasable $product): string 76 | { 77 | $class = get_class($product); 78 | foreach (Relation::$morphMap as $alias => $model) { 79 | if ($model === $class) { 80 | return $alias; 81 | } 82 | } 83 | 84 | return $class; 85 | } 86 | 87 | public function subTotalWithoutDiscounts(): Money 88 | { 89 | return $this->price->multiply($this->quantity); 90 | } 91 | 92 | public function subTotal(): Money 93 | { 94 | return $this->subTotalWithoutDiscounts()->subtract($this->discount()); 95 | } 96 | 97 | public function discount(): Money 98 | { 99 | return $this->discounts->total($this->subTotalWithoutDiscounts(), DiscountTarget::Item)->multiply($this->quantity); 100 | } 101 | 102 | public function tax(AddressInterface $address): Money 103 | { 104 | return taxManager()->taxFor($this->product, $this->subTotal(), $address); 105 | } 106 | 107 | public function total(AddressInterface $address): Money 108 | { 109 | return $this->tax($address)->add($this->subTotal()); 110 | } 111 | 112 | public function unitPriceWithoutDiscounts(): Money 113 | { 114 | return $this->price; 115 | } 116 | 117 | public function unitDiscount(): Money 118 | { 119 | return $this->discounts->total($this->unitPriceWithoutDiscounts(), DiscountTarget::Item); 120 | } 121 | 122 | public function unitPrice(): Money 123 | { 124 | return $this->unitPriceWithoutDiscounts()->subtract($this->unitDiscount()); 125 | } 126 | 127 | public function unitTax(AddressInterface $address): Money 128 | { 129 | return $this->tax($address)->divide($this->quantity); 130 | } 131 | 132 | public function unitTotal(AddressInterface $address): Money 133 | { 134 | return $this->unitPrice()->add($this->unitTax($address)); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/Cart/CartItemBuilder.php: -------------------------------------------------------------------------------- 1 | cart = $cart; 27 | $this->id = (string)Str::uuid(); 28 | } 29 | 30 | public function withId(string $id): self 31 | { 32 | $this->id = $id; 33 | 34 | return $this; 35 | } 36 | 37 | /** 38 | * @param mixed|Purchasable $product 39 | * @return $this 40 | */ 41 | public function withProduct($product): self 42 | { 43 | $this->product = $product; 44 | 45 | if ($product instanceof Purchasable) { 46 | $this->withId($product->cartId()); 47 | $this->withPrice($product->cartPrice()); 48 | $this->withAttributes($product->cartAttributes()); 49 | } 50 | 51 | return $this; 52 | } 53 | 54 | public function withQuantity(float $quantity): self 55 | { 56 | $this->quantity = $quantity; 57 | 58 | return $this; 59 | } 60 | 61 | public function withPrice(Money $price): self 62 | { 63 | $this->price = $price; 64 | 65 | return $this; 66 | } 67 | 68 | public function withAttributes(array $attributes): self 69 | { 70 | $this->attributes = array_merge($this->attributes, $attributes); 71 | 72 | return $this; 73 | } 74 | 75 | public function create(): CartItem 76 | { 77 | $validator = Validator::make($this->getData(), [ 78 | 'id' => [ 79 | 'required', 80 | 'uuid', 81 | ], 82 | 'product' => ['required'], 83 | 'price' => ['required'], 84 | 'quantity' => [ 85 | 'required', 86 | 'numeric', 87 | 'min:0', 88 | ], 89 | 'attributes' => ['array'], 90 | ]); 91 | 92 | if (! $validator->validate()) { 93 | throw new ValidationException($validator); 94 | } 95 | 96 | return new CartItem([ 97 | 'id' => $this->id, 98 | 'product' => $this->product, 99 | 'price' => $this->price, 100 | 'quantity' => $this->quantity, 101 | 'attributes' => $this->attributes, 102 | ]); 103 | } 104 | 105 | public function toCart(Cart $cart) 106 | { 107 | return $cart->driver()->get($this->product); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/Cart/CartItemCollection.php: -------------------------------------------------------------------------------- 1 | sum(function (CartItem $cartItem) { 12 | return $cartItem->quantity; 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Cart/CartItemModel.php: -------------------------------------------------------------------------------- 1 | MoneyIntegerCast::class, 38 | 'product_attributes' => 'collection', 39 | 'discounts' => 'collection', 40 | 'quantity' => 'float', 41 | ]; 42 | 43 | /** 44 | * @var mixed|string 45 | */ 46 | protected $cartKey; 47 | 48 | public function __construct(array $attributes = []) 49 | { 50 | parent::__construct($attributes); 51 | 52 | $this->setTable(config('ecommerce.tables.items', 'cart_items')); 53 | } 54 | 55 | public function user(): BelongsTo 56 | { 57 | return $this->belongsTo(config('ecommerce.classes.user', '\\App\\Models\\User')); 58 | } 59 | 60 | public function product(): MorphTo 61 | { 62 | return $this->morphTo(__FUNCTION__, 'purchasable_type', 'purchasable_id'); 63 | } 64 | 65 | public function getDiscountsAttribute($discounts): DiscountCollection 66 | { 67 | $discounts = $this->castAttribute('discounts', $discounts); 68 | 69 | return DiscountCollection::make($discounts->map(function ($discount) { 70 | return Discount::fromArray($discount); 71 | })); 72 | } 73 | 74 | public function toCartValue(): DataTransferObject 75 | { 76 | return new CartItem([ 77 | 'price' => $this->price, 78 | 'attributes' => $this->product_attributes, 79 | 'discounts' => DiscountCollection::make($this->discounts), 80 | 'product' => $this->product, 81 | 'quantity' => $this->quantity, 82 | ]); 83 | } 84 | 85 | /** 86 | * @param CartItem $cartItem 87 | * @param string $key 88 | * @param string $instanceName 89 | * @return self|Model 90 | */ 91 | public function fromCartValue($cartItem, string $key, string $instanceName): self 92 | { 93 | $data = $this->cartItemData($cartItem, $instanceName); 94 | 95 | try { 96 | $cartItemModel = $this 97 | ->forInstance($instanceName) 98 | ->forCurrentUser() 99 | ->where('cart_item_id', '=', $cartItem->getId()) 100 | ->firstOrFail() 101 | ->fill($data); 102 | } catch (ModelNotFoundException $e) { 103 | $cartItemModel = (new self($data)); 104 | } 105 | 106 | return $cartItemModel 107 | ->product() 108 | ->associate($cartItem->product); 109 | } 110 | 111 | private function cartItemData(CartItem $cartItem, string $instanceName): array 112 | { 113 | return [ 114 | 'cart_item_id' => $cartItem->getId(), 115 | 'user_id' => auth()->user() ? auth()->user()->getAuthIdentifier() : null, 116 | 'session_id' => session()->getId(), 117 | 'instance' => $instanceName, 118 | 'price' => $cartItem->price, 119 | 'product_attributes' => $cartItem->attributes, 120 | 'discounts' => $cartItem->discounts->toArray(), 121 | 'quantity' => $cartItem->quantity, 122 | ]; 123 | } 124 | 125 | public function scopeForCurrentUser(Builder $query): Builder 126 | { 127 | return $query->where(function (Builder $subQuery) { 128 | if (auth()->user()) { 129 | return $subQuery->orWhere('user_id', '=', auth()->user()->getAuthIdentifier()); 130 | } 131 | 132 | return $subQuery->where('session_id', '=', session()->getId()); 133 | }); 134 | } 135 | 136 | public function scopeForInstance(Builder $query, string $instanceName): Builder 137 | { 138 | return $query->where('instance', $instanceName); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Cart/CartManager.php: -------------------------------------------------------------------------------- 1 | app = $app; 18 | } 19 | 20 | public function instance(?string $name = null): CartInterface 21 | { 22 | $name = $name ?: $this->getDefaultInstance(); 23 | 24 | return $this->instances[$name] = $this->get($name); 25 | } 26 | 27 | public function set(string $name, $instance): self 28 | { 29 | $this->instances[$name] = $instance; 30 | 31 | return $this; 32 | } 33 | 34 | public function getDefaultInstance(): string 35 | { 36 | return $this->app['config']['ecommerce.cart.default'] ?? 'cart'; 37 | } 38 | 39 | public function __call($method, $parameters) 40 | { 41 | return $this->instance()->$method(...$parameters); 42 | } 43 | 44 | protected function get(string $name) 45 | { 46 | $class = $this->app['config']['ecommerce.classes.cart']; 47 | 48 | return $this->instances[$name] ?? new $class($this->resolve($name), $name); 49 | } 50 | 51 | protected function resolve($name) 52 | { 53 | $config = $this->getConfig($name); 54 | $storage = $config['storage'] ?? $this->app['config']['ecommerce.storage.default'] ?? 'session'; 55 | 56 | return $this->app['ecommerce.storage']->store($storage, $name); 57 | } 58 | 59 | protected function getConfig(string $name): array 60 | { 61 | return $this->app['config']["ecommerce.cart.instances.{$name}"] ?: []; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Cart/Concern/InteractsWithStorage.php: -------------------------------------------------------------------------------- 1 | storage; 22 | } 23 | 24 | protected function persist(string $key, $value): self 25 | { 26 | $this->storage()->set($key, $value); 27 | 28 | return $this; 29 | } 30 | 31 | public function loadFromStorage(): void 32 | { 33 | $this->loadItemsFromStorage(); 34 | $this->loadCustomerFromStorage(); 35 | $this->loadDiscountsFromStorage(); 36 | } 37 | 38 | protected function loadItemsFromStorage(): void 39 | { 40 | $this->items = CartItemCollection::make( 41 | $this->storage()->get(StorageType::Items->value, []) 42 | )->keyBy(fn (CartItem $item) => $item->getId()); 43 | } 44 | 45 | protected function loadDiscountsFromStorage(): void 46 | { 47 | $this->discounts = DiscountCollection::make( 48 | $this->storage()->get( 49 | StorageType::Discounts->value, 50 | DiscountCollection::make(), 51 | ) 52 | ); 53 | } 54 | 55 | protected function loadCustomerFromStorage(): void 56 | { 57 | $this->customer = $this->storage()->get( 58 | StorageType::Customer->value, 59 | new Customer([ 60 | 'id' => (string) Str::orderedUuid(), 61 | 'shippingAddress' => new Address([ 62 | 'type' => AddressType::Shipping, 63 | ]), 64 | 'billingAddress' => new Address([ 65 | 'type' => AddressType::Billing, 66 | ]), 67 | ]) 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Cart/Event/CartItemAdded.php: -------------------------------------------------------------------------------- 1 | cartItem = $cartItem; 20 | $this->instance = $instance; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Cart/Event/CartItemRemoved.php: -------------------------------------------------------------------------------- 1 | app = $app; 31 | $this->setupCurrencies(); 32 | $this->setupCurrencyConversion($this->app->make(Swap::class)); 33 | } 34 | 35 | public function fromFloat(float $amount, Currency $currency): Money 36 | { 37 | $intValue = $amount * pow(10, $this->availableCurrencies()->subunitFor($currency)); 38 | 39 | return new Money((int) $intValue, $currency); 40 | } 41 | 42 | public function toFloat(Money $amount): float 43 | { 44 | return (float) ($amount->getAmount() / pow(10, $this->availableCurrencies()->subunitFor($amount->getMoney()->getCurrency()))); 45 | } 46 | 47 | public function convert(Money $money, ?Currency $counterCurrency = null, $roundingMode = \Money\Money::ROUND_HALF_UP): Money 48 | { 49 | if ($counterCurrency === null) { 50 | $counterCurrency = $this->userCurrency(); 51 | } 52 | 53 | return Money::convert($this->converter->convert($money->getMoney(), $counterCurrency, $roundingMode)); 54 | } 55 | 56 | public function setUserCurrency(Currency $currency): self 57 | { 58 | if (! $this->isActiveCurrency($currency)) { 59 | $currency = $this->defaultCurrency(); 60 | } 61 | $this->userCurrency = $currency; 62 | 63 | return $this; 64 | } 65 | 66 | public function userCurrency(): Currency 67 | { 68 | return $this->userCurrency ?: $this->defaultCurrency(); 69 | } 70 | 71 | public function isActiveCurrency($currency): bool 72 | { 73 | if (! $currency instanceof Currency) { 74 | $currency = new Currency(strtoupper($currency)); 75 | } 76 | 77 | if (! $this->availableCurrencies()->contains($currency)) { 78 | return false; 79 | } 80 | 81 | return true; 82 | } 83 | 84 | public function currency(string $code): Currency 85 | { 86 | foreach ($this->availableCurrencies() as $currency) { 87 | if (strtolower($currency->getCode()) === strtolower($code)) { 88 | return $currency; 89 | } 90 | } 91 | 92 | throw new UnknownCurrencyException($code); 93 | } 94 | 95 | public function availableCurrencies(): Currencies 96 | { 97 | return $this->availableCurrencies; 98 | } 99 | 100 | public function availableCurrenciesCollection(): Collection 101 | { 102 | return $this->availableCurrenciesCollection; 103 | } 104 | 105 | public function defaultCurrency(): Currency 106 | { 107 | return $this->defaultCurrency; 108 | } 109 | 110 | protected function setupCurrencies(): void 111 | { 112 | $currencyListClass = config('ecommerce.currency.currencies', ISOCurrencies::class); 113 | 114 | if (! class_exists($currencyListClass)) { 115 | $currencyListClass = ISOCurrencies::class; 116 | } 117 | 118 | $this->availableCurrencies = $this->app->make($currencyListClass); 119 | $this->availableCurrenciesCollection = Collection::make($this->availableCurrencies); 120 | Money::setCurrencies($this->availableCurrencies); 121 | 122 | $sessionCurrency = session(config('ecommerce.currency.session_key', 'ecommerce.currency'), config('ecommerce.currency.user', config('ecommerce.currency.default', 'USD'))); 123 | 124 | $this->defaultCurrency = $this->currency(config('ecommerce.currency.default', 'USD')); 125 | $this->userCurrency = $this->currency($sessionCurrency); 126 | } 127 | 128 | protected function setupCurrencyConversion(Swap $swap): void 129 | { 130 | $this->exchange = new ReversedCurrenciesExchange( 131 | new SwapExchange($swap) 132 | ); 133 | $this->converter = new Converter($this->availableCurrencies(), $this->exchange); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/Customer/Customer.php: -------------------------------------------------------------------------------- 1 | AddressType::Billing, 33 | ]); 34 | $parameters['shippingAddress'] ??= new Address([ 35 | 'type' => AddressType::Shipping, 36 | ]); 37 | 38 | $parameters['id'] ??= sha1((string)Str::orderedUuid()); 39 | 40 | parent::__construct($parameters); 41 | } 42 | 43 | public function getId(): string 44 | { 45 | return $this->id; 46 | } 47 | 48 | public function taxAddress(): AddressInterface 49 | { 50 | $taxAddressType = config('ecommerce.tax.address_type', AddressType::Shipping); 51 | 52 | if ($taxAddressType === AddressType::Shipping) { 53 | return $this->shippingAddress; 54 | } 55 | 56 | if ($taxAddressType === AddressType::Billing) { 57 | return $this->billingAddress; 58 | } 59 | 60 | return new StoreAddress(); 61 | } 62 | 63 | public function toJson($options = 0): string 64 | { 65 | return json_encode($this->toArray(), $options); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Customer/CustomerManager.php: -------------------------------------------------------------------------------- 1 | DTOCast::class . ':' . Address::class, 29 | 'shipping_address' => DTOCast::class . ':' . Address::class, 30 | ]; 31 | 32 | public function __construct(array $attributes = []) 33 | { 34 | parent::__construct($attributes); 35 | 36 | $this->setTable(config('ecommerce.tables.customers', 'cart_customers')); 37 | } 38 | 39 | public function user(): BelongsTo 40 | { 41 | $this->belongsTo(config('ecommerce.classes.user', '\\App\\Models\\User')); 42 | } 43 | 44 | public function toCartValue(): DataTransferObject 45 | { 46 | $userModel = config('ecommerce.classes.user', '\\App\\Models\\User'); 47 | 48 | return new Customer([ 49 | 'id' => $this->getKey(), 50 | 'user' => $this->user_id ? $userModel::find($this->user_id) : null, 51 | 'shippingAddress' => $this->shipping_address, 52 | 'billingAddress' => $this->billing_address, 53 | ]); 54 | } 55 | 56 | /** 57 | * @param Customer $customer 58 | * @param string $key 59 | * @param string $instanceName 60 | * @return StoresEcommerceData 61 | */ 62 | public function fromCartValue($customer, string $key, string $instanceName): StoresEcommerceData 63 | { 64 | if ($customer->user) { 65 | try { 66 | return self::query() 67 | ->where('user_id', '=', $customer->user->getKey()) 68 | ->firstOrFail() 69 | ->fill([ 70 | 'shipping_address' => $customer->shippingAddress, 71 | 'billing_address' => $customer->billingAddress, 72 | ]); 73 | } catch (ModelNotFoundException $e) { 74 | } 75 | } 76 | 77 | return $this->loadOrCreateFromCustomerId($customer); 78 | } 79 | 80 | private function loadOrCreateFromCustomerId(Customer $customer): self 81 | { 82 | try { 83 | return self::query() 84 | ->where($this->getKeyName(), '=', $customer->getId()) 85 | ->firstOrFail() 86 | ->fill([ 87 | 'session_id' => session()->getId(), 88 | 'user_id' => $customer->user ? $customer->user->getKey() : null, 89 | 'shipping_address' => $customer->shippingAddress, 90 | 'billing_address' => $customer->billingAddress, 91 | ]); 92 | } catch (ModelNotFoundException $e) { 93 | return (new self([ 94 | 'id' => $customer->getId(), 95 | 'session_id' => session()->getId(), 96 | 'user_id' => $customer->user ? $customer->user->getKey() : null, 97 | 'shipping_address' => $customer->shippingAddress, 98 | 'billing_address' => $customer->billingAddress, 99 | ])); 100 | } 101 | } 102 | 103 | public function scopeForCurrentUser(Builder $query): Builder 104 | { 105 | return $query->where(function (Builder $subQuery) { 106 | $subQuery->where('session_id', '=', session()->getId()); 107 | 108 | if (auth()->user()) { 109 | $subQuery->orWhere('user_id', '=', auth()->user()->getAuthIdentifier()); 110 | } 111 | 112 | return $subQuery; 113 | }); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Discount/Discount.php: -------------------------------------------------------------------------------- 1 | type === DiscountType::Percentage) { 32 | return $price->multiply((string) ($this->value / 100)); 33 | } 34 | 35 | return $this->value; 36 | } 37 | 38 | public function target(): DiscountTarget 39 | { 40 | return $this->target; 41 | } 42 | 43 | public function toJson($options = 0): string 44 | { 45 | return json_encode($this->toArray(), $options); 46 | } 47 | 48 | public static function fromArray(array $discount): self 49 | { 50 | $type = DiscountType::tryFrom($discount['type']); 51 | 52 | return new Discount([ 53 | 'type' => $type, 54 | 'target' => DiscountTarget::tryFrom($discount['target']), 55 | 'value' => $type === DiscountType::Value 56 | ? new Money($discount['value']['amount'] ?? 0, $discount['value']['currency'] ?? 'USD') 57 | : (float)$discount['value'], 58 | 'attributes' => collect($discount['attributes'] ?? []), 59 | ]); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Discount/DiscountCollection.php: -------------------------------------------------------------------------------- 1 | filter(fn(Discount $discount) => $discount->target === $target); 13 | } 14 | 15 | public function total(Money $price): Money 16 | { 17 | if ($this->filter()->count() <= 0) { 18 | return new Money(0, currencyManager()->defaultCurrency()); 19 | } 20 | 21 | return $this->filter()->reduce(function (?Money $sum = null, ?Discount $discount = null) use ($price) { 22 | if ($sum === null) { 23 | return $discount->calculateValue($price); 24 | } 25 | 26 | return $sum->add($discount->calculateValue($price)); 27 | }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Discount/DiscountModel.php: -------------------------------------------------------------------------------- 1 | DiscountTarget::class, 31 | 'type' => DiscountType::class, 32 | 'currency' => CurrencyCast::class, 33 | 'discount_attributes' => 'collection', 34 | ]; 35 | 36 | public function __construct(array $attributes = []) 37 | { 38 | parent::__construct($attributes); 39 | 40 | $this->setTable(config('ecommerce.tables.discounts', 'discounts')); 41 | } 42 | 43 | public function getValueAttribute($value) 44 | { 45 | if ($this->type === DiscountType::Value) { 46 | return new Money($value, $this->currency); 47 | } 48 | 49 | return $value; 50 | } 51 | 52 | public function toCartValue(): DataTransferObject 53 | { 54 | return new Discount([ 55 | 'type' => $this->type, 56 | 'target' => $this->target, 57 | 'value' => $this->value, 58 | 'attributes' => $this->discount_attributes, 59 | ]); 60 | } 61 | 62 | /** 63 | * @param Discount $discount 64 | * @param string $key 65 | * @param string $instanceName 66 | * @return self|Model 67 | */ 68 | public function fromCartValue($discount, string $key, string $instanceName): self 69 | { 70 | $data = $this->discountData($discount, $instanceName); 71 | 72 | try { 73 | $discountModel = self::query() 74 | ->where('discount_id', '=', $discount->id) 75 | ->firstOrFail() 76 | ->fill($data); 77 | } catch (ModelNotFoundException $e) { 78 | $discountModel = (new self($data)); 79 | } 80 | 81 | return $discountModel; 82 | } 83 | 84 | private function discountData(Discount $discount, string $instanceName): array 85 | { 86 | return [ 87 | 'discount_id' => $discount->id, 88 | 'user_id' => auth()->user() ? auth()->user()->getAuthIdentifier() : null, 89 | 'session_id' => session()->getId(), 90 | 'instance' => $instanceName, 91 | 'value' => $discount->value instanceof Money ? $discount->value->getAmount() : $discount->value, 92 | 'currency' => $discount->value instanceof Money ? $discount->value->getCurrency() : null, 93 | 'type' => $discount->type->value, 94 | 'target' => $discount->target->value, 95 | 'discount_attributes' => $discount->attributes->toArray(), 96 | ]; 97 | } 98 | 99 | public function scopeForCurrentUser(Builder $query): Builder 100 | { 101 | return $query->where(function (Builder $subQuery) { 102 | if (auth()->user()) { 103 | return $subQuery->orWhere('user_id', '=', auth()->user()->getAuthIdentifier()); 104 | } 105 | 106 | return $subQuery->where('session_id', '=', session()->getId()); 107 | }); 108 | } 109 | 110 | public function scopeForInstance(Builder $query, string $instanceName): Builder 111 | { 112 | return $query->where('instance', $instanceName); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Discount/DiscountTarget.php: -------------------------------------------------------------------------------- 1 | publishResources(); 43 | $this->addMoneyConfig(); 44 | $this->addStateMachineConfig(); 45 | } 46 | 47 | /** 48 | * Register the application services. 49 | */ 50 | public function register() 51 | { 52 | $this->mergeConfigFrom(__DIR__ . '/../config/ecommerce.php', 'ecommerce'); 53 | 54 | $this->addStateMachineConfig(); 55 | $this->registerStorageManager(); 56 | $this->registerCurrencyManager(); 57 | $this->registerTaxClasses(); 58 | $this->registerAddressClasses(); 59 | $this->registerCartManager(); 60 | 61 | $this->registerFacades(); 62 | } 63 | 64 | protected function registerCurrencyManager() 65 | { 66 | $this->app->singleton('ecommerce.currency', function ($app) { 67 | $class = $this->app['config']['ecommerce.classes.currencyManager'] ?? CurrencyManager::class; 68 | 69 | return new $class($app); 70 | }); 71 | } 72 | 73 | protected function registerCartManager() 74 | { 75 | $this->app->singleton('ecommerce.cart', function ($app) { 76 | $class = $this->app['config']['ecommerce.classes.cartManager'] ?? CartManager::class; 77 | 78 | return new $class($app); 79 | }); 80 | } 81 | 82 | protected function registerTaxClasses() 83 | { 84 | $this->app->singleton('ecommerce.tax.resolver', TaxResolver::class); 85 | 86 | $this->app->singleton('ecommerce.tax.chainTaxRateResolver', function ($app) { 87 | $chainTaxRateResolver = new ChainTaxRateResolver(); 88 | $chainTaxRateResolver->addResolver(new DefaultTaxRateResolver()); 89 | 90 | return $chainTaxRateResolver; 91 | }); 92 | 93 | $this->app->singleton('ecommerce.tax.chainTaxTypeResolver', function ($app) { 94 | $taxTypeRepository = new TaxTypeRepository(); 95 | $chainTaxTypeResolver = new ChainTaxTypeResolver(); 96 | $chainTaxTypeResolver->addResolver(new CanadaTaxTypeResolver($taxTypeRepository)); 97 | $chainTaxTypeResolver->addResolver(new EuTaxTypeResolver($taxTypeRepository)); 98 | $chainTaxTypeResolver->addResolver(new DefaultTaxTypeResolver($taxTypeRepository)); 99 | 100 | return $chainTaxTypeResolver; 101 | }); 102 | 103 | $this->app->bind(TaxResolverInterface::class, TaxResolver::class); 104 | $this->app->bind(ChainTaxRateResolverInterface::class, ChainTaxRateResolver::class); 105 | $this->app->bind(ChainTaxTypeResolverInterface::class, ChainTaxTypeResolver::class); 106 | 107 | $this->app->when(TaxResolver::class) 108 | ->needs('$chainTaxTypeResolver') 109 | ->give(app('ecommerce.tax.chainTaxTypeResolver')); 110 | 111 | $this->app->when(TaxResolver::class) 112 | ->needs('$chainTaxRateResolver') 113 | ->give(app('ecommerce.tax.chainTaxRateResolver')); 114 | 115 | $this->app->bind('ecommerce.tax', function($app) { 116 | $taxManagerClass = $this->app['config']['ecommerce.classes.taxManager'] ?? TaxManager::class; 117 | return new $taxManagerClass($app->make(TaxResolverInterface::class), $app->make(VatCalculator::class)); 118 | }); 119 | } 120 | 121 | protected function registerAddressClasses() 122 | { 123 | $this->app->bind(AddressFormatRepositoryInterface::class, AddressFormatRepository::class); 124 | $this->app->bind(CountryRepositoryInterface::class, CountryRepository::class); 125 | $this->app->bind(SubdivisionRepositoryInterface::class, SubdivisionRepository::class); 126 | 127 | $this->app->singleton('ecommerce.addressFormatRepository', AddressFormatRepository::class); 128 | $this->app->singleton('ecommerce.countryRepository', CountryRepository::class); 129 | $this->app->singleton('ecommerce.subdivisionRepository', SubdivisionRepository::class); 130 | 131 | $this->app->when(PostalLabelFormatter::class) 132 | ->needs('$defaultOptions') 133 | ->give([ 134 | 'origin_country' => config('ecommerce.store.address.country', 'IT'), 135 | ]); 136 | } 137 | 138 | protected function registerStorageManager() 139 | { 140 | $this->app->singleton('ecommerce.storage', function ($app) { 141 | $class = $this->app['config']['ecommerce.classes.storageManager'] ?? StorageManager::class; 142 | 143 | return new $class($app); 144 | }); 145 | } 146 | 147 | protected function registerFacades() 148 | { 149 | $this->app->alias('ecommerce.storage', Cart::class); 150 | $this->app->alias('ecommerce.cart', Cart::class); 151 | $this->app->alias('ecommerce.currency', Currency::class); 152 | $this->app->alias('ecommerce.tax', TaxManager::class); 153 | } 154 | 155 | public function provides() 156 | { 157 | return [ 158 | 'ecommerce.storage', 159 | 'ecommerce.cart', 160 | 'ecommerce.currency', 161 | 'ecommerce.tax', 162 | ]; 163 | } 164 | 165 | protected function publishResources(): void 166 | { 167 | if (! $this->app->runningInConsole()) { 168 | return; 169 | } 170 | 171 | $this->publishes([ 172 | __DIR__ . '/../config/ecommerce.php' => config_path('ecommerce.php'), 173 | ], 'config'); 174 | 175 | if (! class_exists('CreateCartitemsTable')) { 176 | $timestamp = date('Y_m_d_His', time()); 177 | 178 | $this->publishes([ 179 | __DIR__ . '/../database/migrations/0000_00_00_000000_create_cartitems_table.php' => database_path('migrations/' . $timestamp . '_create_cartitems_table.php'), 180 | ], 'migrations'); 181 | 182 | $this->loadMigrationsFrom(__DIR__ . '/../database/migrations/'); 183 | } 184 | } 185 | 186 | protected function addStateMachineConfig(): void 187 | { 188 | $stateMachineConfigKeys = [ 189 | 'order', 190 | 'payment', 191 | ]; 192 | 193 | $keys = []; 194 | foreach ($stateMachineConfigKeys as $key) { 195 | $graphKey = 'ecommerce.' . $key . '.workflow'; 196 | $configKey = config($graphKey . '.graph', 'ecommerce-' . $key); 197 | 198 | $stateMachineClass = config($graphKey . '.state_machine_class', config('ecommerce.classes.stateMachine', StateMachine::class)); 199 | $config = config()->get($graphKey); 200 | $config['state_machine_class'] = $stateMachineClass; 201 | 202 | config()->set('state-machine.' . $configKey, $config); 203 | $keys[$graphKey] = config($graphKey . '.graph', 'ecommerce-' . $key); 204 | } 205 | 206 | 207 | 208 | $this->app->extend('sm.factory', function ($service, $app) use ($keys) { 209 | foreach ($keys as $graphKey => $key) { 210 | $service->addConfig(config()->get($graphKey), $key); 211 | } 212 | 213 | return $service; 214 | }); 215 | 216 | $this->app->resolving('sm.factory', function ($service, $app) use ($keys) { 217 | foreach ($keys as $graphKey => $key) { 218 | $service->addConfig(config()->get($graphKey), $key); 219 | } 220 | }); 221 | } 222 | 223 | protected function addMoneyConfig(): void 224 | { 225 | Money::setLocale(config()->get('ecommerce.customer.locale', 'en_US')); 226 | Money::setDefaultCurrency(config()->get('ecommerce.currency.default', 'USD')); 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /src/Order/Concern/Payable.php: -------------------------------------------------------------------------------- 1 | hasMany(config('ecommerce.classes.paymentModel', Payment::class)); 18 | } 19 | 20 | public function createPayment(): Payment 21 | { 22 | return $this->payments()->create([ 23 | 'total' => $this->total, 24 | 'payment_gateway' => $this->payment_gateway, 25 | 'state' => PaymentState::Created, 26 | 'currency' => $this->currency, 27 | ]); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Order/Order.php: -------------------------------------------------------------------------------- 1 | DTOCast::class . ':' . Customer::class, 25 | 'currency' => CurrencyCast::class, 26 | 'discounts' => 'collection', 27 | 'discounts_subtotal' => MoneyIntegerCast::class . ':currency', 28 | 'items_subtotal' => MoneyIntegerCast::class . ':currency', 29 | 'items_total' => MoneyIntegerCast::class . ':currency', 30 | 'subtotal' => MoneyIntegerCast::class . ':currency', 31 | 'tax' => MoneyIntegerCast::class . ':currency', 32 | 'total' => MoneyIntegerCast::class . ':currency', 33 | 'state' => OrderState::class, 34 | ]; 35 | 36 | public function __construct(array $attributes = []) 37 | { 38 | parent::__construct($attributes); 39 | 40 | $this->setTable(config('ecommerce.tables.orders', 'orders')); 41 | } 42 | 43 | protected static function booted() 44 | { 45 | static::creating(function (Order $order) { 46 | $order->generateUniqueHash(); 47 | }); 48 | 49 | static::created(function (Order $order) { 50 | $order->createPayment(); 51 | }); 52 | 53 | static::updating(function (Order $order) { 54 | $order->generateUniqueHash(); 55 | }); 56 | } 57 | 58 | public static function fromCart(CartInterface $cart): OrderBuilder 59 | { 60 | /** @var OrderBuilder $builder */ 61 | $builder = config('ecommerce.classes.orderBuilder', OrderBuilder::class); 62 | return (new $builder)->fromCart($cart); 63 | } 64 | 65 | public function items(): HasMany 66 | { 67 | return $this->hasMany(config('ecommerce.classes.orderItemModel', OrderItem::class)); 68 | } 69 | 70 | public function user(): BelongsTo 71 | { 72 | return $this->belongsTo(config('ecommerce.classes.user')); 73 | } 74 | 75 | protected function generateUniqueHash(): self 76 | { 77 | if ($this->hash) { 78 | return $this; 79 | } 80 | 81 | $hash = $this->generateHash(); 82 | while (self::query()->where('hash', '=', $hash)->count() > 0) { 83 | $hash = $this->generateHash(); 84 | } 85 | 86 | $this->hash = $hash; 87 | 88 | return $this; 89 | } 90 | 91 | protected function generateHash(): string 92 | { 93 | return Str::random(config('ecommerce.order.hash_length', 8)); 94 | } 95 | 96 | protected function getGraph(): string 97 | { 98 | return config('ecommerce.order.workflow.graph', 'ecommerce-order'); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Order/OrderBuilder.php: -------------------------------------------------------------------------------- 1 | order = new $class; 19 | $this->items = Collection::make([]); 20 | 21 | $this->order->fill([ 22 | 'payment_gateway' => config('ecommerce.payment.gateway', config('omnipay.gateway', env('OMNIPAY_GATEWAY', 'PayPal_Express'))), 23 | ]); 24 | } 25 | 26 | public function fromCart(CartInterface $cart): self 27 | { 28 | $this->order 29 | ->fill([ 30 | 'user_id' => $cart->customer()->user ? $cart->customer()->user->id : null, 31 | 'customer_id' => $cart->customer()->getId() ?: null, 32 | 'customer' => $cart->customer(), 33 | 'currency' => $cart->total()->getMoney()->getCurrency()->getCode(), 34 | 'discounts' => $cart->discounts(), 35 | 'discounts_subtotal' => $cart->discount(), 36 | 'items_subtotal' => $cart->itemsSubtotal(), 37 | 'subtotal' => $cart->subTotal(), 38 | 'tax' => $cart->tax(), 39 | 'total' => $cart->total(), 40 | 'state' => config('ecommerce.order.workflow.default_state', OrderState::New->value()), 41 | ]); 42 | 43 | $this->items = $cart->items()->map(function (CartItem $item) { 44 | $item = OrderItem::fromCartItem($item) 45 | ->make(); 46 | 47 | return $item; 48 | })->toBase(); 49 | 50 | return $this; 51 | } 52 | 53 | public function forUser($user): self 54 | { 55 | $this->order->user()->associate($user); 56 | 57 | return $this; 58 | } 59 | 60 | public function fill(array $data): self 61 | { 62 | $this->order->fill($data); 63 | 64 | return $this; 65 | } 66 | 67 | public function withGateway(string $gateway): self 68 | { 69 | $this->order->fill([ 70 | 'payment_gateway' => $gateway, 71 | ]); 72 | 73 | return $this; 74 | } 75 | 76 | public function create(): Order 77 | { 78 | DB::transaction(function () { 79 | $this->order->save(); 80 | $this->items->each(function (OrderItem $item) { 81 | $item->order()->associate($this->order)->save(); 82 | }); 83 | }); 84 | 85 | if (config('ecommerce.order.clear_cart', true)) { 86 | cartManager()->clear(); 87 | } 88 | 89 | return $this->order; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Order/OrderItem.php: -------------------------------------------------------------------------------- 1 | 'collection', 30 | 'discounts' => 'collection', 31 | 'quantity' => 'float', 32 | 'unit_price' => MoneyIntegerCast::class, 33 | 'discounts_subtotal' => MoneyIntegerCast::class, 34 | 'subtotal' => MoneyIntegerCast::class, 35 | 'purchasable_data' => 'collection', 36 | ]; 37 | 38 | public function __construct(array $attributes = []) 39 | { 40 | parent::__construct($attributes); 41 | 42 | $this->setTable(config('ecommerce.tables.order_items', 'order_items')); 43 | } 44 | 45 | public static function fromCartItem(CartItem $cartItem): OrderItemBuilder 46 | { 47 | /** @var OrderItemBuilder $builder */ 48 | $builder = config('ecommerce.classes.orderItemBuilder', OrderItemBuilder::class); 49 | return (new $builder)->fromCartItem($cartItem); 50 | } 51 | 52 | public function order(): BelongsTo 53 | { 54 | return $this->belongsTo(config('ecommerce.classes.orderModel', Order::class)); 55 | } 56 | 57 | public function product(): MorphTo 58 | { 59 | return $this->morphTo(__FUNCTION__, 'purchasable_type', 'purchasable_id'); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Order/OrderItemBuilder.php: -------------------------------------------------------------------------------- 1 | orderItem = new $class; 15 | } 16 | 17 | public function fromCartItem(CartItem $cartItem): OrderItemBuilder 18 | { 19 | $this->orderItem 20 | ->fill([ 21 | 'quantity' => $cartItem->quantity, 22 | 'product_attributes' => $cartItem->attributes, 23 | 'purchasable_data' => $cartItem->product->toJson(), 24 | 'discounts' => $cartItem->discounts, 25 | 'unit_price' => $cartItem->unitPrice(), 26 | 'discounts_subtotal' => $cartItem->discount(), 27 | 'subtotal' => $cartItem->subTotal(), 28 | ]); 29 | 30 | $this->orderItem 31 | ->product() 32 | ->associate($cartItem->product); 33 | 34 | return $this; 35 | } 36 | 37 | public function make(): OrderItem 38 | { 39 | return $this->orderItem; 40 | } 41 | 42 | public function create(): OrderItem 43 | { 44 | $this->orderItem->save(); 45 | 46 | return $this->orderItem; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Order/OrderState.php: -------------------------------------------------------------------------------- 1 | value; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Order/OrderTransition.php: -------------------------------------------------------------------------------- 1 | value; 17 | } 18 | 19 | public function name(): string 20 | { 21 | return ucfirst($this->value()); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Order/StateHistory.php: -------------------------------------------------------------------------------- 1 | setTable(config('ecommerce.tables.state_history', 'ecommerce_state_history')); 21 | } 22 | 23 | public function model(): MorphTo 24 | { 25 | return $this->morphTo(); 26 | } 27 | 28 | public function statable() 29 | { 30 | return $this->model(); 31 | } 32 | 33 | public function user(): BelongsTo 34 | { 35 | return $this->belongsTo(User::class, 'actor_id'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Payment/Callback/CreatePayment.php: -------------------------------------------------------------------------------- 1 | getStateMachine(); 13 | 14 | /** @var Order $model */ 15 | $order = $sm->getObject(); 16 | $order->createPayment(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Payment/Callback/MarkOrderAs.php: -------------------------------------------------------------------------------- 1 | transition = $transition; 16 | } 17 | 18 | public function __invoke(TransitionEvent $event) 19 | { 20 | $sm = $event->getStateMachine(); 21 | 22 | /** @var Payment $payment */ 23 | $payment = $sm->getObject(); 24 | $payment->order->apply($this->transition); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Payment/Payment.php: -------------------------------------------------------------------------------- 1 | DTOCast::class . ':' . Customer::class, 22 | 'currency' => CurrencyCast::class, 23 | 'discounts' => 'collection', 24 | 'discounts_subtotal' => MoneyIntegerCast::class . ':currency', 25 | 'items_subtotal' => MoneyIntegerCast::class . ':currency', 26 | 'items_total' => MoneyIntegerCast::class . ':currency', 27 | 'subtotal' => MoneyIntegerCast::class . ':currency', 28 | 'tax' => MoneyIntegerCast::class . ':currency', 29 | 'total' => MoneyIntegerCast::class . ':currency', 30 | 'state' => PaymentState::class, 31 | ]; 32 | 33 | public function __construct(array $attributes = []) 34 | { 35 | parent::__construct($attributes); 36 | 37 | $this->setTable(config('ecommerce.tables.payments', 'payments')); 38 | } 39 | 40 | protected function getGraph(): string 41 | { 42 | return config('ecommerce.payment.workflow.graph', 'ecommerce-payment'); 43 | } 44 | 45 | public function order(): BelongsTo 46 | { 47 | return $this->belongsTo(config('ecommerce.classes.orderModel', Order::class)); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Payment/PaymentState.php: -------------------------------------------------------------------------------- 1 | value; 19 | } 20 | 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/Payment/PaymentTransition.php: -------------------------------------------------------------------------------- 1 | value; 18 | } 19 | 20 | public function name(): string 21 | { 22 | return ucfirst($this->value()); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Price/HasTotals.php: -------------------------------------------------------------------------------- 1 | getValue($this->object, $this->config['property_path']); 14 | 15 | if ($state instanceof StateInterface) { 16 | return $state->value(); 17 | } 18 | 19 | return $state; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Storage/CacheStorage.php: -------------------------------------------------------------------------------- 1 | get($sessionKey, Str::orderedUuid()); 26 | 27 | $this->cache = Cache::store($driver); 28 | $this->prefix = $sessionPrefix . "." . ($config['prefix'] ?? 'ecommerce.'); 29 | } 30 | 31 | public function setInstanceName(string $name): StorageInterface 32 | { 33 | $this->instanceName = $name; 34 | $this->prefix .= '.' . $this->instanceName; 35 | 36 | return $this; 37 | } 38 | 39 | public function set(string $key, $value): StorageInterface 40 | { 41 | $this->cache->set($this->prefix . $key, $value); 42 | 43 | return $this; 44 | } 45 | 46 | public function get(string $key, $default = null) 47 | { 48 | return $this->cache->get($this->prefix . $key, $default); 49 | } 50 | 51 | public function has(string $key): bool 52 | { 53 | return $this->cache->has($this->prefix . $key); 54 | } 55 | 56 | public function remove(string $key): StorageInterface 57 | { 58 | $this->cache->forget($this->prefix . $key); 59 | 60 | return $this; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Storage/EloquentStorage.php: -------------------------------------------------------------------------------- 1 | modelClasses = $modelClasses; 20 | $this->fallbackStorage = storageManager()->store($config['fallback'] ?? 'session'); 21 | } 22 | 23 | public function setInstanceName(string $name): StorageInterface 24 | { 25 | $this->instanceName = $name; 26 | 27 | return $this; 28 | } 29 | 30 | public function set(string $key, $value): StorageInterface 31 | { 32 | if (! $this->hasModelFor($key)) { 33 | return $this->fallbackStorage->set($key, $value); 34 | } 35 | 36 | if ($value instanceof Collection) { 37 | $model = $this->modelFor($key); 38 | $oldKeys = $this->modelQueryFor($key) 39 | ->get() 40 | ->pluck($model->getKeyName()); 41 | $newKeys = $value->pluck($model->getKeyName()); 42 | $keysToDelete = $oldKeys->except($newKeys); 43 | 44 | if ($keysToDelete->count() > 0) { 45 | $this 46 | ->modelQueryFor($key) 47 | ->whereIn($model->getKeyName(), $keysToDelete->toArray()) 48 | ->delete(); 49 | } 50 | 51 | $value->each(function ($item) use ($key) { 52 | $this->modelFor($key)->fromCartValue($item, $key, $this->instanceName)->save(); 53 | }); 54 | 55 | return $this; 56 | } 57 | 58 | $this->modelFor($key)->fromCartValue($value, $key, $this->instanceName)->save(); 59 | 60 | return $this; 61 | } 62 | 63 | public function get(string $key, $default = null) 64 | { 65 | if (! $this->hasModelFor($key)) { 66 | return $this->fallbackStorage->get($key, $default); 67 | } 68 | 69 | if ($key !== StorageType::Customer->value) { 70 | $items = $this 71 | ->modelQueryFor($key) 72 | ->get(); 73 | 74 | if ($items->count() <= 0) { 75 | return $default; 76 | } 77 | 78 | return $items->mapWithKeys(function (Model|StoresEcommerceData $model) { 79 | /** @var CartItem $cartItem */ 80 | $cartItem = $model->toCartValue(); 81 | return [$cartItem->getId() => $cartItem]; 82 | }); 83 | } 84 | 85 | try { 86 | return $this 87 | ->modelQueryFor($key) 88 | ->firstOrFail() 89 | ->toCartValue(); 90 | } catch (ModelNotFoundException $e) { 91 | return $default; 92 | } 93 | } 94 | 95 | public function has(string $key): bool 96 | { 97 | if (! $this->hasModelFor($key)) { 98 | return $this->fallbackStorage->has($key); 99 | } 100 | 101 | return $this->modelQueryFor($key)->count() > 0; 102 | } 103 | 104 | public function remove(string $key): StorageInterface 105 | { 106 | if (! $this->hasModelFor($key)) { 107 | return $this->fallbackStorage->remove($key); 108 | } 109 | 110 | if ($key !== StorageType::Customer->value) { 111 | $items = $this->modelQueryFor($key)->get(); 112 | 113 | if ($items->count() <= 0) { 114 | return $this->fallbackStorage->remove($key); 115 | } 116 | 117 | $items->each->delete(); 118 | 119 | return $this; 120 | } 121 | 122 | try { 123 | $this->modelQueryFor($key)->firstOrFail()->delete(); 124 | } catch (ModelNotFoundException $e) { 125 | $this->fallbackStorage->remove($key); 126 | } 127 | 128 | return $this; 129 | } 130 | 131 | protected function hasModelFor(string $key): bool 132 | { 133 | return $this->modelClassFor($key) !== null; 134 | } 135 | 136 | protected function modelClassFor(string $key): ?string 137 | { 138 | switch ($key) { 139 | case StorageType::Customer->value: 140 | return $this->modelClasses['customerModel'] ?? null; 141 | 142 | case StorageType::Discounts->value: 143 | return $this->modelClasses['discountModel'] ?? null; 144 | 145 | case StorageType::Items->value: 146 | return $this->modelClasses['cartItemModel'] ?? null; 147 | } 148 | 149 | return null; 150 | } 151 | 152 | protected function modelFor(string $key): ?StoresEcommerceData 153 | { 154 | $class = $this->modelClassFor($key); 155 | if (! $class) { 156 | return null; 157 | } 158 | 159 | return (new $class); 160 | } 161 | 162 | protected function modelQueryFor(string $key): ?Builder 163 | { 164 | $model = $this->modelFor($key); 165 | if (! $model) { 166 | return null; 167 | } 168 | 169 | if ($model instanceof StoresDifferentInstances) { 170 | $model = $model->forInstance($this->instanceName); 171 | } 172 | 173 | return $model->forCurrentUser(); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/Storage/SessionStorage.php: -------------------------------------------------------------------------------- 1 | session = app()->make(SessionManager::class)->driver($driver); 23 | $this->prefix = $config['prefix'] ?? 'ecommerce.'; 24 | } 25 | 26 | public function setInstanceName(string $name): StorageInterface 27 | { 28 | $this->instanceName = $name; 29 | 30 | return $this; 31 | } 32 | 33 | public function set(string $key, $value): self 34 | { 35 | $this->session->put($this->prefix() . $key, $value); 36 | 37 | return $this; 38 | } 39 | 40 | public function get(string $key, $default = null) 41 | { 42 | return $this->session->get($this->prefix() . $key, $default); 43 | } 44 | 45 | public function has(string $key): bool 46 | { 47 | return $this->session->has($this->prefix() . $key); 48 | } 49 | 50 | public function remove(string $key): self 51 | { 52 | $this->session->put($this->prefix() . $key, null); 53 | 54 | return $this; 55 | } 56 | 57 | protected function prefix(): string 58 | { 59 | return $this->prefix . $this->instanceName . '.'; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Storage/StorageInterface.php: -------------------------------------------------------------------------------- 1 | driver($driver, $name); 13 | } 14 | 15 | public function driver($driver = null, $name = null) 16 | { 17 | $driver = $driver ?: $this->getDefaultDriver(); 18 | $name = $name ?: $driver; 19 | 20 | if (is_null($driver)) { 21 | throw new InvalidArgumentException(sprintf( 22 | 'Unable to resolve NULL driver for [%s].', static::class 23 | )); 24 | } 25 | 26 | // If the given driver has not been created before, we will create the instances 27 | // here and cache it so we can return it next time very quickly. If there is 28 | // already a driver created by this name, we'll just return that instance. 29 | if (! isset($this->drivers[$driver][$name])) { 30 | $this->drivers[$driver][$name] = $this->createDriver($driver)->setInstanceName($name); 31 | } 32 | 33 | return $this->drivers[$driver][$name]; 34 | } 35 | 36 | public function getDefaultDriver(): string 37 | { 38 | return $this->config['ecommerce.storage.default'] ?? 'session'; 39 | } 40 | 41 | public function createSessionDriver(): SessionStorage 42 | { 43 | $config = $this->getConfig('session'); 44 | 45 | return new SessionStorage($config); 46 | } 47 | 48 | public function createCacheDriver(): CacheStorage 49 | { 50 | $config = $this->getConfig('cache'); 51 | 52 | return new CacheStorage($config); 53 | } 54 | 55 | public function createEloquentDriver(): EloquentStorage 56 | { 57 | $config = $this->getConfig('eloquent'); 58 | 59 | return new EloquentStorage($config, $this->config['ecommerce.classes'] ?? []); 60 | } 61 | 62 | protected function getConfig(string $storeName): array 63 | { 64 | return $this->config['ecommerce.storage.stores.' . $storeName] ?? []; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Storage/StorageType.php: -------------------------------------------------------------------------------- 1 | getCode(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Support/DTOCast.php: -------------------------------------------------------------------------------- 1 | class = $class; 15 | } 16 | 17 | public function get($model, string $key, $value, array $attributes) 18 | { 19 | return (new $this->class(json_decode($value, true))); 20 | } 21 | 22 | public function set($model, string $key, $value, array $attributes) 23 | { 24 | if ($value instanceof Jsonable) { 25 | return $value->toJson(); 26 | } 27 | 28 | return json_encode($value); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Support/InteractsWithStateMachine.php: -------------------------------------------------------------------------------- 1 | hasPossibleTransition($name)) { 21 | $this->apply($name); 22 | 23 | return $this; 24 | } 25 | 26 | return parent::__call($name, $arguments); 27 | } 28 | 29 | public function apply(TransitionInterface $transition, $soft = false, $context = []) 30 | { 31 | if ($this->statableApply($transition->value(), $soft, $context)) { 32 | $this->save(); 33 | 34 | return $this; 35 | } 36 | 37 | throw new InvalidTransitionException($transition, $this); 38 | } 39 | 40 | public function stateIs(): string 41 | { 42 | return $this->state()->value(); 43 | } 44 | 45 | public function state(): StateInterface 46 | { 47 | $graph = $this->getGraph(); 48 | $property = config('state-machine.' . $graph . '.property_path', 'state'); 49 | 50 | return $this->{$property}; 51 | } 52 | 53 | public function stateHistory(): MorphMany 54 | { 55 | return $this->morphMany(config('ecommerce.classes.stateHistoryModel', StateHistory::class), 'model'); 56 | } 57 | 58 | public function addHistoryLine(array $transitionData) 59 | { 60 | if ($this->getKey()) { 61 | $transitionData['actor_id'] = $this->getActorId(); 62 | $this->stateHistory()->create($transitionData); 63 | } 64 | } 65 | 66 | abstract protected function getGraph(): string; 67 | 68 | protected function saveBeforeTransition(): bool 69 | { 70 | return true; 71 | } 72 | 73 | public function possibleTransitions(): Collection 74 | { 75 | if ($this->getKey() === null) { 76 | return collect([]); 77 | } 78 | 79 | return collect($this->stateMachine()->getPossibleTransitions()); 80 | } 81 | 82 | public function hasPossibleTransition($name): bool 83 | { 84 | return $this->possibleTransitions()->contains($name); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Support/InvalidTransitionException.php: -------------------------------------------------------------------------------- 1 | transition = $transition; 17 | $this->model = $model; 18 | 19 | parent::__construct("Invalid transition {$transition} applied to model " . get_class($model)); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Support/StateHistoryManager.php: -------------------------------------------------------------------------------- 1 | storeHistory($event); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Tax/TaxManager.php: -------------------------------------------------------------------------------- 1 | vatCalculator = $vatCalculator; 27 | $this->taxResolver = $taxResolver; 28 | $this->storeAddress = new StoreAddress(); 29 | 30 | $this->vatCalculator->setBusinessCountryCode($this->storeAddress->getCountryCode()); 31 | } 32 | 33 | public function resolver(): TaxResolverInterface 34 | { 35 | return $this->taxResolver; 36 | } 37 | 38 | public function taxFor(Purchasable|TaxableInterface $product, ?Money $price = null, ?AddressInterface $address = null): Money 39 | { 40 | 41 | if ($address === null) { 42 | $address = $this->storeAddress; 43 | } 44 | 45 | if ($product instanceof Purchasable && $price === null) { 46 | $price = $product->cartPrice(); 47 | } 48 | 49 | if ($price === null) { 50 | throw new Exception("Price cannot be null when calculating taxes"); 51 | } 52 | 53 | if ($this->vatCalculator->shouldCollectVAT($address->getCountryCode())) { 54 | return $this->vatFor($price, $address); 55 | } 56 | 57 | return $this->genericTaxFor($price, $address, $product); 58 | } 59 | 60 | public function vatFor(Money $price, AddressInterface $address, ?string $vatId = null): Money 61 | { 62 | $isCompany = $this->isValidEUCompany($address, $vatId); 63 | $this->vatCalculator->calculate((int) $price->getAmount(), $address->getCountryCode(), $address->getPostalCode(), $isCompany); 64 | 65 | $tax = (int) $this->vatCalculator->getTaxValue(); 66 | 67 | return new Money((string) $tax, $price->getCurrency()); 68 | } 69 | 70 | public function genericTaxFor(Money $price, ?AddressInterface $address, TaxableInterface|Purchasable $product): Money 71 | { 72 | $currency = $price->getMoney()->getCurrency(); 73 | $context = new Context($address, $this->storeAddress); 74 | 75 | /** @var TaxRateAmount[] $amounts */ 76 | $amounts = $this->taxResolver->resolveAmounts($product, $context); 77 | 78 | if (count($amounts) <= 0) { 79 | return new Money(0, $currency); 80 | } 81 | 82 | $amount = array_shift($amounts)->getAmount(); 83 | 84 | /** @var Money $tax */ 85 | $tax = $price->multiply((string)$amount); 86 | 87 | return $tax; 88 | } 89 | 90 | private function isValidEUCompany(AddressInterface $address, ?string $vatId = null): bool 91 | { 92 | if (!$address->getOrganization()) { 93 | return false; 94 | } 95 | 96 | if (!$address instanceof Address) { 97 | return false; 98 | } 99 | 100 | $vatId = $vatId ?? $address->getVatId(); 101 | 102 | if (!$vatId) { 103 | return false; 104 | } 105 | 106 | $shouldCheckVatId = config('ecommerce.tax.vat_id_check', true); 107 | if (!$shouldCheckVatId) { 108 | return true; 109 | } 110 | 111 | try { 112 | return $this->vatCalculator->isValidVATNumber($vatId); 113 | } catch (VATCheckUnavailableException) { 114 | return false; 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 |