diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000..14602a0b --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,143 @@ +version: 2.1 + +defaults: &defaults + environment: &environment + CIRCLE_TEST_REPORTS: /tmp/test-results + CIRCLE_ARTIFACTS: /tmp/test-artifacts + BUNDLE_JOBS: 4 + BUNDLE_RETRY: 3 + BUNDLE_PATH: ~/spree/vendor/bundle + working_directory: ~/spree + docker: + - image: &ruby_image cimg/ruby:3.4-browsers + +run_tests: &run_tests + <<: *defaults + steps: + - checkout + - restore_cache: + keys: + - spree-bundle-{{ .Branch }} + - run: + name: Add keyserver + command: sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 4EB27DB2A3B88B8B + - run: + name: Install libvips + command: sudo apt-get update && sudo apt-get install libvips + - run: + name: Set bundle path + command: bundle config --local path vendor/bundle + - run: + name: Ensure bundle Install + command: | + bundle check || bundle install + - run: + name: Create test app + command: | + bundle exec rake test_app + - run: + name: Run Rspec + command: | + TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) + bundle exec rspec --format documentation \ + --format RspecJunitFormatter \ + -o ~/rspec/rspec.xml \ + -- ${TESTFILES} + - store_test_results: + path: ~/rspec + - store_artifacts: + path: tmp/capybara + +jobs: + bundle: + <<: *defaults + steps: + - checkout + - restore_cache: + keys: + - spree-bundle-{{ .Branch }} + - run: + name: Add keyserver + command: sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 4EB27DB2A3B88B8B + - run: + name: Install libvips + command: sudo apt-get update && sudo apt-get install libvips + - run: + name: Set bundle path + command: bundle config --local path vendor/bundle + - run: + name: Bundle Install + command: | + bundle check || bundle install + - save_cache: + paths: + - vendor/bundle + key: spree-bundle-{{ .Branch }} + + tests_postgres: &tests_postgres + <<: *run_tests + environment: &postgres_environment + <<: *environment + DB: postgres + DB_HOST: localhost + DB_USERNAME: postgres + docker: + - image: *ruby_image + - image: &postgres_image cimg/postgres:16.2 + environment: + POSTGRES_USER: postgres + + tests_mysql: &tests_mysql + <<: *run_tests + environment: &mysql_environment + <<: *environment + DB: mysql + DB_HOST: 127.0.0.1 + DB_USERNAME: root + COVERAGE: true + COVERAGE_DIR: /tmp/workspace/simplecov + docker: + - image: *ruby_image + - image: &mysql_image cimg/mysql:8.0 + + brakeman: + <<: *defaults + steps: + - checkout + - restore_cache: + keys: + - spree-bundle-{{ .Branch }} + - run: + name: Set bundle path + command: bundle config --local path vendor/bundle + - run: + name: Ensure bundle Install + command: | + bundle check || bundle install + - run: + name: Brakeman + command: bundle exec brakeman --exit-on-warn --exit-on-error -o brakeman.json -o brakeman.html + - run: + name: Move brakeman reports to artifacts directory + command: | + mkdir -p /tmp/test-artifacts/ + mv brakeman.json /tmp/test-artifacts/ + mv brakeman.html /tmp/test-artifacts/ + - store_artifacts: + path: /tmp/test-artifacts + destination: brakeman-reports + +workflows: + version: 2 + main: + jobs: + - bundle + - tests_postgres: + requires: + - bundle + - tests_mysql: + requires: + - bundle + - brakeman: + requires: + - bundle \ No newline at end of file diff --git a/.gem_release.yml b/.gem_release.yml new file mode 100644 index 00000000..9511c758 --- /dev/null +++ b/.gem_release.yml @@ -0,0 +1,3 @@ +bump: + file: "lib/spree_multi_vendor/version.rb" + diff --git a/.github/.dependabot.yml b/.github/.dependabot.yml new file mode 100644 index 00000000..c496bc7a --- /dev/null +++ b/.github/.dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "bundler" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 452ebb34..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,7 +0,0 @@ -version: 2 -updates: -- package-ecosystem: bundler - directory: "/" - schedule: - interval: daily - open-pull-requests-limit: 10 diff --git a/.gitignore b/.gitignore index abfb6263..edb1978a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,9 @@ .idea .localeapp/locales .project +.vscode coverage +default Gemfile.lock tmp nbproject @@ -17,5 +19,6 @@ spec/dummy public/spree .ruby-version .ruby-gemset -gemfiles/*.gemfile.lock -*.gem \ No newline at end of file +*.gem +*/*.gem +.rspec_status diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 00000000..51320aa3 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,24 @@ +plugins: + - rubocop-rails + +AllCops: + DisplayCopNames: true + TargetRubyVersion: 3.3 + Include: + - '**/Gemfile' + - '**/Rakefile' + - '**/Appraisals' + Exclude: + - 'spec/dummy/**/*' + - 'lib/generators/**/*' + +Layout/LineLength: + Max: 150 + +# DISABLED + +Style/Documentation: + Enabled: false + +Style/FrozenStringLiteralComment: + Enabled: false diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 28f40216..00000000 --- a/.travis.yml +++ /dev/null @@ -1,37 +0,0 @@ -os: linux -dist: bionic - -addons: - apt: - sources: - - google-chrome - packages: - - google-chrome-stable - -services: - - mysql - - postgresql - - redis-server - -language: ruby - -rvm: - - 2.7 - - 3.0 - -env: - - DB=mysql - - DB=postgres - -before_install: - - mysql -u root -e "GRANT ALL ON *.* TO 'travis'@'%';" - -before_script: - - CHROME_MAIN_VERSION=`google-chrome-stable --version | sed -E 's/(^Google Chrome |\.[0-9]+ )//g'` - - CHROMEDRIVER_VERSION=`curl -s "https://chromedriver.storage.googleapis.com/LATEST_RELEASE_$CHROME_MAIN_VERSION"` - - curl "https://chromedriver.storage.googleapis.com/${CHROMEDRIVER_VERSION}/chromedriver_linux64.zip" -O - - unzip chromedriver_linux64.zip -d ~/bin - -script: - - bundle exec rake test_app - - bundle exec rake spec diff --git a/Gemfile b/Gemfile index cbf8f536..aac7a2d6 100644 --- a/Gemfile +++ b/Gemfile @@ -1,10 +1,23 @@ source 'https://rubygems.org' -gem 'spree', github: 'spree/spree', branch: 'main' -gem 'spree_backend', github: 'spree/spree_backend', branch: 'main' -gem 'spree_frontend', github: 'spree/spree_legacy_frontend', branch: 'main' -gem 'spree_emails', github: 'spree/spree', branch: 'main' -gem 'spree_auth_devise', github: 'spree/spree_auth_devise', branch: 'main' +git_source(:github) do |repo_name| + repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?('/') + "https://github.com/#{repo_name}.git" +end + gem 'rails-controller-testing' +spree_opts = { github: 'spree/spree', branch: 'main' } +gem 'spree', spree_opts +gem 'spree_emails', spree_opts +gem 'spree_admin', spree_opts +gem 'spree_storefront', spree_opts + +gem 'mysql2' if ENV['DB'] == 'mysql' || ENV['CI'] +gem 'pg' if ENV['DB'] == 'postgres' || ENV['CI'] + +gem 'sqlite3', '>= 2.0' + gemspec + +gem "sprockets-rails", "~> 3.5" diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 86a1a841..00000000 --- a/LICENSE +++ /dev/null @@ -1,26 +0,0 @@ -Copyright (c) 2017 Spark Solutions Sp. z o.o. -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - - * Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - * Neither the name Spree nor the names of its contributors may be used to - endorse or promote products derived from this software without specific - prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR -CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, -EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, -PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..4942e85f --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,14 @@ +# LICENSE + +Copyright (c) 2025 Vendo Connect Inc., Vendo Sp. z o.o. +All rights reserved. + +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/). \ No newline at end of file diff --git a/README.md b/README.md index 9d6f2165..bfe70205 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,59 @@ -# Spree Commerce Multi Vendor Marketplace Open Source +# Spree Multi vendor -This is a Spree Commerce open-source [multi vendor marketplace](https://spreecommerce.org/marketplace-ecommerce/) extension. It's a great starting point if you're building a marketplace on top of [Spree](https://spreecommerce.org). Our goal was flexibility to allow you to tweak it to your needs. +This is a Multi vendor extension for [Spree Commerce](https://spreecommerce.org), an open source e-commerce platform built with Ruby on Rails. ## Installation -Please [follow our Multi-Vendor quickstart guide](https://spreecommerce.org/docs/developer/multi-vendor/quickstart). +1. Add this extension to your Gemfile with this line: -## Community Edition vs Enterprise Edition + ```ruby + bundle add spree_multi_vendor + ``` -If you need a product that has all of the marketplace features out of the box, such as supplier onboarding, Stripe Connect payment splitting and payouts, and doesn't require much custom development, we recommend using [Spree Enterprise Edition](https://spreecommerce.org/pricing/). +2. Run the install generator -### [Multi-vendor Marketplace](https://spreecommerce.org/marketplace-ecommerce/) Enterprise Edition -Run your own marketplace with multiple suppliers, each with a dedicated supplier dashboard -- [Marketplace eCommerce Capabilities](https://spreecommerce.org/docs/use-case/marketplace/capabilities) -- [Marketplace eCommerce Admin Panel](https://spreecommerce.org/docs/use-case/marketplace/admin-dashboard) -- [Marketplace eCommerce Vendor Panel](https://spreecommerce.org/docs/use-case/marketplace/vendor-dashboard) -- [Marketplace eCommerce Customer Experience](https://spreecommerce.org/docs/use-case/marketplace/customer-ux) + ```ruby + bundle exec rails g spree_multi_vendor:install + ``` -## License +3. Restart your server -Spree Multi-Vendor extension is free software, and may be redistributed under the terms specified in the -[LICENCE](LICENSE) file. + If your server was running, restart it so that it can find the assets properly. -[LICENSE]: https://github.com/spree-contrib/spree_multi_vendor/blob/main/LICENSE +## Developing + +1. Create a dummy app + + ```bash + bundle update + bundle exec rake test_app + ``` + +2. Add your new code +3. Run tests + + ```bash + bundle exec rspec + ``` + +When testing your applications integration with this extension you may use it's factories. +Simply add this require statement to your spec_helper: + +```ruby +require 'spree_multi_vendor/factories' +``` + +## Releasing a new version + +```shell +bundle exec gem bump -p -t +bundle exec gem release +``` + +For more options please see [gem-release README](https://github.com/svenfuchs/gem-release) + +## Contributing + +If you'd like to contribute, please take a look at the +[instructions](CONTRIBUTING.md) for installing dependencies and crafting a good +pull request. diff --git a/Rakefile b/Rakefile index 1e600b23..79e49802 100644 --- a/Rakefile +++ b/Rakefile @@ -7,9 +7,9 @@ require 'spree/testing_support/extension_rake' RSpec::Core::RakeTask.new task :default do - if Dir["spec/dummy"].empty? + if Dir['spec/dummy'].empty? Rake::Task[:test_app].invoke - Dir.chdir("../../") + Dir.chdir('../../') end Rake::Task[:spec].invoke end diff --git a/app/.gitkeep b/app/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/app/assets/config/spree_multi_vendor_manifest.js b/app/assets/config/spree_multi_vendor_manifest.js new file mode 100644 index 00000000..61bb407b --- /dev/null +++ b/app/assets/config/spree_multi_vendor_manifest.js @@ -0,0 +1,5 @@ +//= link_tree ../images +//= link spree_multi_vendor/application.js +//= link_tree ../../javascript/spree_multi_vendor/controllers .js +//= link_tree ../../../vendor/javascript .js +//= link_tree ../../../vendor/stylesheets .css \ No newline at end of file diff --git a/app/assets/images/.keep b/app/assets/images/.keep new file mode 100644 index 00000000..e69de29b diff --git a/app/controllers/concerns/spree_multi_vendor/core/controller_helpers/order_decorator.rb b/app/controllers/concerns/spree_multi_vendor/core/controller_helpers/order_decorator.rb new file mode 100644 index 00000000..f5d84f3f --- /dev/null +++ b/app/controllers/concerns/spree_multi_vendor/core/controller_helpers/order_decorator.rb @@ -0,0 +1,18 @@ +module SpreeMultiVendor + module Core + module ControllerHelpers + module OrderDecorator + def user_orders_scope + try_spree_current_user.orders. + without_vendor. + incomplete. + not_canceled. + where.not(id: current_order.id). + where(store_id: current_store.id) + end + end + end + end +end + +Spree::Core::ControllerHelpers::Order.prepend(SpreeMultiVendor::Core::ControllerHelpers::OrderDecorator) diff --git a/app/controllers/spree/admin/marketplace_settings_controller.rb b/app/controllers/spree/admin/marketplace_settings_controller.rb new file mode 100644 index 00000000..d2fa31e9 --- /dev/null +++ b/app/controllers/spree/admin/marketplace_settings_controller.rb @@ -0,0 +1,29 @@ +module Spree + module Admin + class MarketplaceSettingsController < BaseController + include Spree::Admin::SettingsConcern + + def edit + end + + def update + if current_store.update(permitted_params) + remove_assets(%w[shopify_app_image], object: current_store) + redirect_to spree.edit_admin_marketplace_settings_path, notice: flash_message_for(current_store, :successfully_updated) + else + render :edit, status: :unprocessable_entity + end + end + + private + + def permitted_params + params.require(:store).permit(:preferred_platform_fee, :preferred_vendor_payouts_schedule_interval, :preferred_test_mode, :vendor_terms_of_service, :shopify_app_image) + end + + def model_class + Spree::Store + end + end + end +end diff --git a/app/controllers/spree/admin/vendor_settings_controller.rb b/app/controllers/spree/admin/vendor_settings_controller.rb index b1719256..4b90d290 100644 --- a/app/controllers/spree/admin/vendor_settings_controller.rb +++ b/app/controllers/spree/admin/vendor_settings_controller.rb @@ -1,33 +1,35 @@ module Spree module Admin - class VendorSettingsController < Spree::Admin::BaseController - before_action :authorize - before_action :load_vendor + class VendorSettingsController < BaseController + include Spree::Admin::SettingsConcern + + before_action :require_vendor + + def edit + end def update - if vendor_params[:image] - @vendor.create_image(attachment: vendor_params[:image]) - end - if @vendor.update(vendor_params.except(:image)) - redirect_to admin_vendor_settings_path + if current_vendor.update(permitted_params) + redirect_to spree.edit_admin_vendor_settings_path, notice: flash_message_for(current_vendor, :successfully_updated) else - render :edit + render :edit, status: :unprocessable_entity end end private - def authorize - authorize! :manage, :vendor_settings + def permitted_params + params.require(:vendor).permit(:name, :billing_email, + billing_address_attributes: permitted_address_attributes, + returns_address_attributes: permitted_address_attributes) end - def load_vendor - @vendor = current_spree_vendor - raise ActiveRecord::RecordNotFound unless @vendor + def model_class + Spree::Vendor end - def vendor_params - params.require(:vendor).permit(Spree::PermittedAttributes.vendor_attributes) + def require_vendor + redirect_to spree.admin_dashboard_path if current_vendor.nil? end end end diff --git a/app/controllers/spree/admin/vendors_controller.rb b/app/controllers/spree/admin/vendors_controller.rb index 1fd1a7a8..c2a33217 100644 --- a/app/controllers/spree/admin/vendors_controller.rb +++ b/app/controllers/spree/admin/vendors_controller.rb @@ -1,55 +1,124 @@ module Spree module Admin class VendorsController < ResourceController - include Translatable + before_action :authorize_vendor_admin + + add_breadcrumb Spree.t(:vendors), :admin_vendors_path + add_breadcrumb_icon 'heart-handshake' + + def show + @returns_address = @vendor.returns_address + @billing_address = @vendor.billing_address + @vendor_users = @vendor.users.includes(role_users: :role).where(role_users: { resource: @vendor }) + + add_breadcrumb @vendor.name, spree.admin_vendor_path(@vendor) + end + + def new + @vendor = Spree::Vendor.new + @vendor.platform_fee = current_store.preferred_platform_fee + end def create - if permitted_resource_params[:image] - @vendor.build_image(attachment: permitted_resource_params.delete(:image)) + @vendor = Spree::Vendor.new(permitted_resource_params) + @vendor.platform_fee ||= current_store.preferred_platform_fee + @vendor.invited_by = try_spree_current_user + + if @vendor.save + respond_to do |format| + format.html do + flash[:success] = "Invitation email was sent to #{@vendor.contact_person_email}" + redirect_to spree.admin_vendor_path(@vendor) + end + format.turbo_stream do + flash.now[:success] = "Invitation email was sent to #{@vendor.contact_person_email}" + end + end + else + respond_to do |format| + format.html { render :new } + format.turbo_stream + end end - super end - def update - if permitted_resource_params[:image] - @vendor.create_image(attachment: permitted_resource_params.delete(:image)) - end - super + def approve + @vendor.approve! + flash[:success] = "#{@vendor.name} is now approved and all of their active products are now available on the storefront for purchase" + redirect_back fallback_location: spree.admin_vendor_path(@vendor) end - def update_positions - params[:positions].each do |id, position| - vendor = Spree::Vendor.find(id) - vendor.set_list_position(position) - end + def reject + @vendor.reject! + flash[:success] = "Vendor is now rejected. We've also sent them an email notification" + redirect_back fallback_location: spree.admin_vendor_path(@vendor) + end - respond_to do |format| - format.js { render plain: 'Ok' } - end + def suspend + @vendor.suspend! + flash[:success] = "Vendor is now suspended. We've also sent them an email notification" + redirect_back fallback_location: spree.admin_vendor_path(@vendor) + end + + def archive + @vendor.destroy! + flash[:success] = 'Partner is now archived' + redirect_to spree.admin_vendors_path + end + + def confirm_shipping_rates + @vendor.update!(preferred_shipping_rates_confirmed: true) + + flash[:success] = "Shipping rates confirmed, you're all set!" + + redirect_to admin_shipping_methods_path + end + + def select_options + render json: vendors_scope.to_tom_select_json, status: :ok, last_modified: vendors_scope.maximum(:updated_at) end private + def update_turbo_stream_enabled? + true + end + + def collection_includes + [:billing_address, :returns_address, :shipping_methods, :policies, logo_attachment: :blob] + end + def find_resource - Vendor.with_deleted.friendly.find(params[:id]) + vendors_scope.with_deleted.friendly.find(params[:id]) + end + + def vendors_scope + Spree::Vendor + end + + def location_after_save + spree.admin_vendor_path(@vendor) + end + + def create_turbo_stream_enabled? + true end - def collection - params[:q] = {} if params[:q].blank? - vendors = super.order(priority: :asc) - @search = vendors.ransack(params[:q]) + def authorize_vendor_admin + authorize! :manage, Spree::Vendor - @collection = @search.result. - includes(vendor_includes). - page(params[:page]). - per(params[:per_page]) + raise CanCan::AccessDenied if current_vendor.present? && current_vendor != @vendor end - def vendor_includes - { - image: [], - products: [] - } + def permitted_resource_params + params.require(:vendor).permit(:name, + :payouts_schedule_interval, + :platform_fee, + :about, + :logo, :cover_photo, :square_logo, + :contact_person_email, + returns_address_attributes: Spree::PermittedAttributes.address_attributes, + billing_address_attributes: Spree::PermittedAttributes.address_attributes) end end end diff --git a/app/controllers/spree/api/v1/vendors_controller.rb b/app/controllers/spree/api/v1/vendors_controller.rb deleted file mode 100644 index 1c070eed..00000000 --- a/app/controllers/spree/api/v1/vendors_controller.rb +++ /dev/null @@ -1,61 +0,0 @@ -module Spree - module Api - module V1 - class VendorsController < Spree::Api::V1::BaseController - def index - @vendors = if params[:ids].present? - scope.where(id: params[:ids].split(',')) - else - scope.load.ransack( - params[:q] - ).result - end - respond_with(@vendors) - end - - def show - @vendor = scope.find(params[:id]) - authorize! :show, @vendor - respond_with(@vendor) - end - - def create - authorize! :create, Spree::Vendor - @vendor = Spree::Vendor.new(vendor_params) - if @vendor.save - render :show, status: 201 - else - invalid_resource!(@vendor) - end - end - - def update - @vendor = scope.find(params[:id]) - authorize! :update, @vendor - if @vendor.update(vendor_params) - render :show - else - invalid_resource!(@vendor) - end - end - - def destroy - @vendor = scope.find(params[:id]) - authorize! :destroy, @vendor - @vendor.destroy - render plain: nil, status: 204 - end - - private - - def scope - Spree::Vendor.accessible_by(current_ability) - end - - def vendor_params - params.require(:vendor).permit(Spree::PermittedAttributes.vendor_attributes) - end - end - end - end -end diff --git a/app/controllers/spree/api/v2/platform/vendors_controller.rb b/app/controllers/spree/api/v2/platform/vendors_controller.rb new file mode 100644 index 00000000..9978dfe4 --- /dev/null +++ b/app/controllers/spree/api/v2/platform/vendors_controller.rb @@ -0,0 +1,20 @@ +module Spree::Api::V2::Platform + class VendorsController < ResourceController + VENDOR_EVENT_NAMES = %i[invite start_onboarding complete_onboarding approve reject suspend].freeze + + VENDOR_EVENT_NAMES.each do |event_name| + define_method event_name do + resource.send(event_name) + if resource.errors.empty? + render_serialized_payload { serialize_resource(resource) } + else + render_error_payload(resource.errors) + end + end + end + + def model_class + Spree::Vendor + end + end +end diff --git a/app/controllers/spree/api/v2/storefront/vendors_controller.rb b/app/controllers/spree/api/v2/storefront/vendors_controller.rb index ff3176c7..f28a02a2 100644 --- a/app/controllers/spree/api/v2/storefront/vendors_controller.rb +++ b/app/controllers/spree/api/v2/storefront/vendors_controller.rb @@ -3,19 +3,18 @@ module Api module V2 module Storefront class VendorsController < ::Spree::Api::V2::ResourceController - private def model_class - ::Spree::Vendor + Spree::Vendor end def scope - ::Spree::Vendor.active + Spree::Vendor.approved end def resource - scope.find_by(slug: params[:id]) || scope.find(params[:id]) + scope.friendly.find(params[:id]) end def resource_serializer @@ -26,6 +25,9 @@ def collection_serializer Spree::V2::Storefront::VendorSerializer end + def serializer_params + super.merge(include_products: action_name == 'show') + end end end end diff --git a/app/controllers/spree_multi_vendor/account/orders_controller_decorator.rb b/app/controllers/spree_multi_vendor/account/orders_controller_decorator.rb new file mode 100644 index 00000000..10238028 --- /dev/null +++ b/app/controllers/spree_multi_vendor/account/orders_controller_decorator.rb @@ -0,0 +1,16 @@ +module SpreeMultiVendor + module Account + module OrdersControllerDecorator + private + + def load_order_details + @order = orders_scope.find_by!(number: params[:id]) + @shipments = @order.all_shipments.includes(:stock_location, :address, selected_shipping_rate: :shipping_method, inventory_units: :line_item) + end + end + end +end + +if defined?(Spree::Account::OrdersController) + Spree::Account::OrdersController.prepend(SpreeMultiVendor::Account::OrdersControllerDecorator) +end diff --git a/app/controllers/spree_multi_vendor/admin/admin_users_controller_decorator.rb b/app/controllers/spree_multi_vendor/admin/admin_users_controller_decorator.rb new file mode 100644 index 00000000..3cfa8d16 --- /dev/null +++ b/app/controllers/spree_multi_vendor/admin/admin_users_controller_decorator.rb @@ -0,0 +1,15 @@ +module SpreeMultiVendor + module Admin + module AdminUsersControllerDecorator + def load_parent + @parent = if current_vendor.present? + current_vendor + else + super + end + end + end + end +end + +Spree::Admin::AdminUsersController.prepend(SpreeMultiVendor::Admin::AdminUsersControllerDecorator) diff --git a/app/controllers/spree_multi_vendor/admin/base_controller_decorator.rb b/app/controllers/spree_multi_vendor/admin/base_controller_decorator.rb new file mode 100644 index 00000000..641382d4 --- /dev/null +++ b/app/controllers/spree_multi_vendor/admin/base_controller_decorator.rb @@ -0,0 +1,57 @@ +module SpreeMultiVendor + module Admin + module BaseControllerDecorator + def self.prepended(base) + base.helper 'spree/admin/vendor' + base.include(Spree::Admin::VendorHelper) + + base.helper_method :current_vendor + base.helper_method :vendor_state_options + + base.prepend_before_action :set_current_vendor + base.before_action :set_vendor_id, only: [:create, :update] + end + + def set_vendor_id + return unless current_vendor + return unless defined?(resource) + return unless resource + return unless params[resource.object_name.to_sym] + return unless resource.respond_to?(:vendor_id) + + params[resource.object_name.to_sym][:vendor_id] = current_vendor.id + end + + def set_current_vendor + return if params[:vendor_id].blank? + return unless try_spree_current_user + return if try_spree_current_user.spree_admin? + + vendor = try_spree_current_user.vendors.with_access_to_ui.find_by(id: params[:vendor_id]) + return if vendor.blank? + + @current_vendor = vendor + session[:current_vendor_id] = @current_vendor.id + end + + def current_vendor + return unless defined?(try_spree_current_user) + return if try_spree_current_user.nil? + return unless try_spree_current_user.respond_to?(:vendors) + + return @current_vendor if @current_vendor.present? + return if @current_vendor_fetched + + @current_vendor_fetched = true + + return if try_spree_current_user.spree_admin? + + vendor_scope = try_spree_current_user.vendors.with_access_to_ui + + @current_vendor ||= session[:current_vendor_id].present? ? vendor_scope.find_by(id: session[:current_vendor_id]) : vendor_scope.first + end + end + end +end + +Spree::Admin::BaseController.prepend(SpreeMultiVendor::Admin::BaseControllerDecorator) diff --git a/app/controllers/spree_multi_vendor/admin/checkouts_controller_decorator.rb b/app/controllers/spree_multi_vendor/admin/checkouts_controller_decorator.rb new file mode 100644 index 00000000..237bc482 --- /dev/null +++ b/app/controllers/spree_multi_vendor/admin/checkouts_controller_decorator.rb @@ -0,0 +1,11 @@ +module SpreeMultiVendor + module Admin + module CheckoutsControllerDecorator + def self.prepended(base) + base.before_action :load_vendor, only: [:index] + end + end + end +end + +Spree::Admin::CheckoutsController.prepend(SpreeMultiVendor::Admin::CheckoutsControllerDecorator) diff --git a/app/controllers/spree_multi_vendor/admin/imports_controller_decorator.rb b/app/controllers/spree_multi_vendor/admin/imports_controller_decorator.rb new file mode 100644 index 00000000..36859fa4 --- /dev/null +++ b/app/controllers/spree_multi_vendor/admin/imports_controller_decorator.rb @@ -0,0 +1,15 @@ +module SpreeMultiVendor + module Admin + module ImportsControllerDecorator + def set_owner + if current_vendor.present? + @object.owner = current_vendor + else + super + end + end + end + end +end + +Spree::Admin::ImportsController.prepend(SpreeMultiVendor::Admin::ImportsControllerDecorator) diff --git a/app/controllers/spree_multi_vendor/admin/invitations_controller_decorator.rb b/app/controllers/spree_multi_vendor/admin/invitations_controller_decorator.rb new file mode 100644 index 00000000..8057358f --- /dev/null +++ b/app/controllers/spree_multi_vendor/admin/invitations_controller_decorator.rb @@ -0,0 +1,15 @@ +module SpreeMultiVendor + module Admin + module InvitationsControllerDecorator + def load_parent + @parent = if current_vendor.present? + current_vendor + else + super + end + end + end + end +end + +Spree::Admin::InvitationsController.prepend(SpreeMultiVendor::Admin::InvitationsControllerDecorator) diff --git a/app/controllers/spree_multi_vendor/admin/orders_controller_decorator.rb b/app/controllers/spree_multi_vendor/admin/orders_controller_decorator.rb new file mode 100644 index 00000000..e772b51b --- /dev/null +++ b/app/controllers/spree_multi_vendor/admin/orders_controller_decorator.rb @@ -0,0 +1,35 @@ +module SpreeMultiVendor + module Admin + module OrdersControllerDecorator + def self.prepended(base) + base.before_action :load_vendor, only: [:index] + base.before_action :show_only_main_orders, only: :index + end + + private + + def load_order_items + super + + @line_items = @order.all_line_items.includes(variant: [:product, :option_values]) + @shipments = @order.all_shipments.includes( + :inventory_units, :selected_shipping_rate, :vendor, + order: :vendor, shipping_rates: [:shipping_method, :tax_rate] + ).order(:created_at) + + @suborders = @order.vendor_orders.includes(:vendor) if @order.splitted? + @customer_returns = @order.all_customer_returns + end + + # for marketplace operators we should show the main orders, not vendor sub-orders + def show_only_main_orders + return if current_vendor.present? + + params[:q] ||= {} + params[:q][:without_vendor] = true + end + end + end +end + +Spree::Admin::OrdersController.prepend(SpreeMultiVendor::Admin::OrdersControllerDecorator) diff --git a/app/controllers/spree_multi_vendor/admin/products_controller_decorator.rb b/app/controllers/spree_multi_vendor/admin/products_controller_decorator.rb new file mode 100644 index 00000000..cf5ff8dd --- /dev/null +++ b/app/controllers/spree_multi_vendor/admin/products_controller_decorator.rb @@ -0,0 +1,40 @@ +module SpreeMultiVendor + module Admin + module ProductsControllerDecorator + def self.prepended(base) + base.before_action :load_vendor, only: :index + base.before_action :strip_platform_fee_param, only: :update + + base.new_action.before :set_product_vendor + end + + def assign_extra_collection_params + load_vendor + params[:q][:vendor_id_eq] = @vendor.id if @vendor.present? && params[:q].present? + end + + private + + def load_vendor + @vendor = Spree::Vendor.friendly.find(params[:vendor_id]) if params[:vendor_id].present? && !current_vendor.present? + end + + def set_product_vendor + # Needed for ability checks + @product.vendor = current_vendor + end + + def strip_platform_fee_param + return if can? :manage_commission, @product + + params[:product].delete(:platform_fee) + end + + def master_stock_items_locations_opts + { vendor: @product.vendor || current_vendor } + end + end + end +end + +Spree::Admin::ProductsController.prepend(SpreeMultiVendor::Admin::ProductsControllerDecorator) diff --git a/app/controllers/spree_multi_vendor/admin/resource_controller_decorator.rb b/app/controllers/spree_multi_vendor/admin/resource_controller_decorator.rb new file mode 100644 index 00000000..f933686b --- /dev/null +++ b/app/controllers/spree_multi_vendor/admin/resource_controller_decorator.rb @@ -0,0 +1,15 @@ +module SpreeMultiVendor + module Admin + module ResourceControllerDecorator + protected + + def build_resource + resource = super + resource.vendor = current_vendor if resource.has_attribute?(:vendor_id) + resource + end + end + end +end + +Spree::Admin::ResourceController.prepend(SpreeMultiVendor::Admin::ResourceControllerDecorator) diff --git a/app/controllers/spree_multi_vendor/admin/role_users_controller_decorator.rb b/app/controllers/spree_multi_vendor/admin/role_users_controller_decorator.rb new file mode 100644 index 00000000..d43ad37d --- /dev/null +++ b/app/controllers/spree_multi_vendor/admin/role_users_controller_decorator.rb @@ -0,0 +1,15 @@ +module SpreeMultiVendor + module Admin + module RoleUsersControllerDecorator + def load_parent + @parent = if current_vendor.present? + current_vendor + else + super + end + end + end + end +end + +Spree::Admin::RoleUsersController.prepend(SpreeMultiVendor::Admin::RoleUsersControllerDecorator) diff --git a/app/controllers/spree_multi_vendor/admin/shipping_methods_controller_decorator.rb b/app/controllers/spree_multi_vendor/admin/shipping_methods_controller_decorator.rb new file mode 100644 index 00000000..11c8ecc9 --- /dev/null +++ b/app/controllers/spree_multi_vendor/admin/shipping_methods_controller_decorator.rb @@ -0,0 +1,15 @@ +module SpreeMultiVendor + module Admin + module ShippingMethodsControllerDecorator + # for admin users we need to return only shipping methods without vendor + def collection + # for vendors we're returning vendor methods only + return super.includes(:vendor).with_vendor(current_vendor.id).order(created_at: :asc) if current_vendor.present? + + super.without_vendor.order(created_at: :asc) + end + end + end +end + +Spree::Admin::ShippingMethodsController.prepend(SpreeMultiVendor::Admin::ShippingMethodsControllerDecorator) diff --git a/app/controllers/spree_multi_vendor/admin/variants_controller_decorator.rb b/app/controllers/spree_multi_vendor/admin/variants_controller_decorator.rb new file mode 100644 index 00000000..5a349f84 --- /dev/null +++ b/app/controllers/spree_multi_vendor/admin/variants_controller_decorator.rb @@ -0,0 +1,15 @@ +module SpreeMultiVendor + module Admin + module VariantsControllerDecorator + private + + def build_stock_items + available_stock_locations(vendor: @product.vendor).each do |stock_location| + @variant.stock_items.build(stock_location: stock_location) unless @variant.stock_items.exists?(stock_location: stock_location) + end + end + end + end +end + +Spree::Admin::VariantsController.prepend(SpreeMultiVendor::Admin::VariantsControllerDecorator) diff --git a/app/controllers/spree_multi_vendor/api/v2/base_controller_decorator.rb b/app/controllers/spree_multi_vendor/api/v2/base_controller_decorator.rb new file mode 100644 index 00000000..6a365805 --- /dev/null +++ b/app/controllers/spree_multi_vendor/api/v2/base_controller_decorator.rb @@ -0,0 +1,25 @@ +module SpreeMultiVendor + module Api + module V2 + module BaseControllerDecorator + # for compatibility reasons + def try_spree_current_user + spree_current_user + end + + def current_vendor + return @current_vendor if @current_vendor.present? + return if try_spree_current_user.nil? + return unless try_spree_current_user.respond_to?(:vendors) + return unless try_spree_current_user.respond_to?(:has_spree_role?) + + if try_spree_current_user.vendors.any? && try_spree_current_user.has_spree_role?(:admin) + @current_vendor ||= try_spree_current_user.vendors.first + end + end + end + end + end +end + +Spree::Api::V2::BaseController.prepend(SpreeMultiVendor::Api::V2::BaseControllerDecorator) diff --git a/app/controllers/spree_multi_vendor/api/v2/platform/orders_controller_decorator.rb b/app/controllers/spree_multi_vendor/api/v2/platform/orders_controller_decorator.rb new file mode 100644 index 00000000..83103971 --- /dev/null +++ b/app/controllers/spree_multi_vendor/api/v2/platform/orders_controller_decorator.rb @@ -0,0 +1,19 @@ +module SpreeMultiVendor + module Api + module V2 + module Platform + module OrdersControllerDecorator + def scope + if action_name == 'index' + super.without_vendor + else + super + end + end + end + end + end + end +end + +Spree::Api::V2::Platform::OrdersController.prepend(SpreeMultiVendor::Api::V2::Platform::OrdersControllerDecorator) diff --git a/app/controllers/spree_multi_vendor/spree/admin/base_controller_decorator.rb b/app/controllers/spree_multi_vendor/spree/admin/base_controller_decorator.rb deleted file mode 100644 index 554b1088..00000000 --- a/app/controllers/spree_multi_vendor/spree/admin/base_controller_decorator.rb +++ /dev/null @@ -1,11 +0,0 @@ -module SpreeMultiVendor::Spree::Admin::BaseControllerDecorator - Spree::Admin::BaseController.include(Spree::Admin::VendorHelper) - - def self.prepended(base) - base.helper_method :current_spree_vendor - base.helper_method :vendor_state_options - base.helper_method :vendor_tabs - end -end - -Spree::Admin::BaseController.prepend SpreeMultiVendor::Spree::Admin::BaseControllerDecorator diff --git a/app/controllers/spree_multi_vendor/spree/admin/products_controller_decorator.rb b/app/controllers/spree_multi_vendor/spree/admin/products_controller_decorator.rb deleted file mode 100644 index ad0903a6..00000000 --- a/app/controllers/spree_multi_vendor/spree/admin/products_controller_decorator.rb +++ /dev/null @@ -1,24 +0,0 @@ -module SpreeMultiVendor::Spree::Admin::ProductsControllerDecorator - def self.prepended(base) - base.before_action :set_vendor_id, only: [:create, :update] - base.before_action :load_vendors, only: [:new, :edit, :update] - end - - def stock - @variants = @product.variants.includes(*variant_stock_includes) - @variants = [@product.master] if @variants.empty? - @stock_locations = Spree::StockLocation.accessible_by(current_ability, :read).where(vendor_id: @product.vendor_id) - if @stock_locations.empty? - flash[:error] = Spree.t(:stock_management_requires_a_stock_location) - redirect_to admin_stock_locations_path - end - end - - private - - def load_vendors - @vendors = Spree::Vendor.order(Arel.sql('LOWER(name)')) - end -end - -Spree::Admin::ProductsController.prepend SpreeMultiVendor::Spree::Admin::ProductsControllerDecorator diff --git a/app/controllers/spree_multi_vendor/spree/admin/resource_controller_decorator.rb b/app/controllers/spree_multi_vendor/spree/admin/resource_controller_decorator.rb deleted file mode 100644 index 44ce5797..00000000 --- a/app/controllers/spree_multi_vendor/spree/admin/resource_controller_decorator.rb +++ /dev/null @@ -1,8 +0,0 @@ -module SpreeMultiVendor::Spree::Admin::ResourceControllerDecorator - def set_vendor_id - return unless current_spree_vendor - params[resource.object_name.to_sym][:vendor_id] = current_spree_vendor.id - end -end - -Spree::Admin::ResourceController.prepend SpreeMultiVendor::Spree::Admin::ResourceControllerDecorator diff --git a/app/controllers/spree_multi_vendor/spree/admin/shipping_methods_controller_decorator.rb b/app/controllers/spree_multi_vendor/spree/admin/shipping_methods_controller_decorator.rb deleted file mode 100644 index b9f57a78..00000000 --- a/app/controllers/spree_multi_vendor/spree/admin/shipping_methods_controller_decorator.rb +++ /dev/null @@ -1,7 +0,0 @@ -module SpreeMultiVendor::Spree::Admin::ShippingMethodsControllerDecorator - def self.prepended(base) - base.before_action :set_vendor_id, only: [:create, :update] - end -end - -Spree::Admin::ShippingMethodsController.prepend SpreeMultiVendor::Spree::Admin::ShippingMethodsControllerDecorator diff --git a/app/controllers/spree_multi_vendor/spree/admin/stock_locations_controller_decorator.rb b/app/controllers/spree_multi_vendor/spree/admin/stock_locations_controller_decorator.rb deleted file mode 100644 index d4e311e9..00000000 --- a/app/controllers/spree_multi_vendor/spree/admin/stock_locations_controller_decorator.rb +++ /dev/null @@ -1,7 +0,0 @@ -module SpreeMultiVendor::Spree::Admin::StockLocationsControllerDecorator - def self.prepended(base) - base.before_action :set_vendor_id, only: [:create, :update] - end -end - -Spree::Admin::StockLocationsController.prepend SpreeMultiVendor::Spree::Admin::StockLocationsControllerDecorator diff --git a/app/controllers/spree_multi_vendor/spree/admin/users_controller_decorator.rb b/app/controllers/spree_multi_vendor/spree/admin/users_controller_decorator.rb deleted file mode 100644 index 2ca6347b..00000000 --- a/app/controllers/spree_multi_vendor/spree/admin/users_controller_decorator.rb +++ /dev/null @@ -1,13 +0,0 @@ -module SpreeMultiVendor::Spree::Admin::UsersControllerDecorator - private - - def user_params - params.require(:user).permit(permitted_user_attributes | - [spree_role_ids: [], - vendor_ids: [], - ship_address_attributes: permitted_address_attributes, - bill_address_attributes: permitted_address_attributes]) - end -end - -Spree::Admin::UsersController.prepend SpreeMultiVendor::Spree::Admin::UsersControllerDecorator diff --git a/app/controllers/spree_multi_vendor/spree/admin/variants_controller_decorator.rb b/app/controllers/spree_multi_vendor/spree/admin/variants_controller_decorator.rb deleted file mode 100644 index de1e3bda..00000000 --- a/app/controllers/spree_multi_vendor/spree/admin/variants_controller_decorator.rb +++ /dev/null @@ -1,7 +0,0 @@ -module SpreeMultiVendor::Spree::Admin::VariantsControllerDecorator - def self.prepended(base) - base.before_action :set_vendor_id, only: [:create, :update] - end -end - -Spree::Admin::VariantsController.prepend SpreeMultiVendor::Spree::Admin::VariantsControllerDecorator diff --git a/app/controllers/spree_multi_vendor/spree/api/base_controller_decorator.rb b/app/controllers/spree_multi_vendor/spree/api/base_controller_decorator.rb deleted file mode 100644 index f0586ab5..00000000 --- a/app/controllers/spree_multi_vendor/spree/api/base_controller_decorator.rb +++ /dev/null @@ -1,17 +0,0 @@ -module SpreeMultiVendor::Spree::Api::BaseControllerDecorator - Spree::Api::V1::BaseController.include(Spree::Api::VendorHelper) if SpreeMultiVendor::Engine.api_v1_available? - - def self.prepended(base) - base.helper_method :current_spree_vendor - end - - private - - def set_vendor_id(resource_name) - return unless current_spree_vendor - - params[resource_name][:vendor_id] = current_spree_vendor.id - end -end - -::Spree::Api::V1::BaseController.prepend SpreeMultiVendor::Spree::Api::BaseControllerDecorator if SpreeMultiVendor::Engine.api_v1_available? diff --git a/app/controllers/spree_multi_vendor/spree/api/v1/products_controller_decorator.rb b/app/controllers/spree_multi_vendor/spree/api/v1/products_controller_decorator.rb deleted file mode 100644 index 4086a050..00000000 --- a/app/controllers/spree_multi_vendor/spree/api/v1/products_controller_decorator.rb +++ /dev/null @@ -1,9 +0,0 @@ -module SpreeMultiVendor::Spree::Api::V1::ProductsControllerDecorator - def self.prepended(base) - base.before_action only: [:create, :update] do - set_vendor_id(:product) - end - end -end - -Spree::Api::V1::ProductsController.prepend SpreeMultiVendor::Spree::Api::V1::ProductsControllerDecorator if SpreeMultiVendor::Engine.api_v1_available? diff --git a/app/controllers/spree_multi_vendor/spree/api/v1/users_controller_decorator.rb b/app/controllers/spree_multi_vendor/spree/api/v1/users_controller_decorator.rb deleted file mode 100644 index cd71a670..00000000 --- a/app/controllers/spree_multi_vendor/spree/api/v1/users_controller_decorator.rb +++ /dev/null @@ -1,13 +0,0 @@ -module SpreeMultiVendor::Spree::Api::V1::UsersControllerDecorator - private - - def user_params - params.require(:user).permit(permitted_user_attributes | - [spree_role_ids: [], - vendor_ids: [], - ship_address_attributes: permitted_address_attributes, - bill_address_attributes: permitted_address_attributes]) - end -end - -Spree::Api::V1::UsersController.prepend SpreeMultiVendor::Spree::Api::V1::UsersControllerDecorator if SpreeMultiVendor::Engine.api_v1_available? diff --git a/app/finders/spree_multi_vendor/orders/find_complete_decorator.rb b/app/finders/spree_multi_vendor/orders/find_complete_decorator.rb new file mode 100644 index 00000000..bece7c0e --- /dev/null +++ b/app/finders/spree_multi_vendor/orders/find_complete_decorator.rb @@ -0,0 +1,11 @@ +module SpreeMultiVendor + module Orders + module FindCompleteDecorator + def scope + user? ? user.orders.without_vendor.complete.includes(order_includes) : Spree::Order.without_vendor.complete.includes(order_includes) + end + end + end +end + +Spree::Orders::FindComplete.prepend(SpreeMultiVendor::Orders::FindCompleteDecorator) diff --git a/app/finders/spree_multi_vendor/products/find_decorator.rb b/app/finders/spree_multi_vendor/products/find_decorator.rb new file mode 100644 index 00000000..04d15a16 --- /dev/null +++ b/app/finders/spree_multi_vendor/products/find_decorator.rb @@ -0,0 +1,15 @@ +module SpreeMultiVendor + module Products + module FindDecorator + protected + + def by_vendor_ids(products) + return products unless vendor_ids? + + products.where(vendor_id: vendor_ids) + end + end + end +end + +Spree::Products::Find.prepend(SpreeMultiVendor::Products::FindDecorator) diff --git a/app/finders/spree_multi_vendor/products_finder_decorator.rb b/app/finders/spree_multi_vendor/products_finder_decorator.rb deleted file mode 100644 index f2e19090..00000000 --- a/app/finders/spree_multi_vendor/products_finder_decorator.rb +++ /dev/null @@ -1,31 +0,0 @@ -module SpreeMultiVendor - module ProductsFinderDecorator - def initialize(scope:, params:, current_currency: nil) - super - - @vendors = params.dig(:filter, :vendor_ids)&.split(',') - end - - def execute - products = by_vendors(super) - - products.distinct - end - - private - - attr_reader :vendors - - def vendors? - vendors.present? - end - - def by_vendors(products) - return products unless vendors? - - products.where(vendor_id: vendors) - end - end -end - -::Spree::Products::Find.prepend(SpreeMultiVendor::ProductsFinderDecorator) diff --git a/app/helpers/spree/admin/vendor_helper.rb b/app/helpers/spree/admin/vendor_helper.rb index 4a60c250..1b9cc6e2 100644 --- a/app/helpers/spree/admin/vendor_helper.rb +++ b/app/helpers/spree/admin/vendor_helper.rb @@ -1,9 +1,19 @@ module Spree module Admin module VendorHelper - def current_spree_vendor - if current_spree_user.vendors.any? && (!current_spree_user.respond_to?(:has_spree_role?) || !current_spree_user.has_spree_role?(:admin)) - current_spree_user.vendors.first + def invite_vendor_button(options = {}) + return unless can?(:create, Spree::Vendor) + + options[:class] ||= 'btn btn-primary' + + content_tag(:div, data: { toggle: 'modal', target: '#modal' }) do + link_to_with_icon( + 'send', + Spree.t(:invite_vendor), + spree.new_admin_vendor_path, + class: options[:class], + data: { turbo_frame: 'dialog_modal' } + ) end end @@ -11,8 +21,155 @@ def vendor_state_options @vendor_state_options ||= Spree::Vendor.state_machines[:state].states.collect { |s| [Spree.t("vendor_states.#{s.name}"), s.value] } end - def vendor_tabs - Rails.application.config.spree_backend.tabs[:vendor] + def vendor_logo_link(vendor = nil, options = {}) + vendor ||= current_vendor + + options[:height] ||= 30 + options[:class] ||= 'd-flex align-items-center font-weight-bold justify-content-start btn' + options[:data] ||= {} + options[:data]['turbo-frame'] ||= '_top' + + if vendor.deleted? + content_tag :span, vendor.display_name, class: options[:class], style: 'text-decoration: line-through;' + else + link_to spree.admin_vendor_path(vendor), class: options[:class], data: options[:data] do + [ + (vendor_logo(vendor, options) || first_letter_icon(vendor.name, options)), + content_tag(:span, class: 'ml-2') { vendor.display_name } + ].compact.join.html_safe + end + end + end + + def vendor_name_link(vendor = nil, options = {}) + vendor ||= current_vendor + + options[:class] ||= 'text-dark' + + if vendor.deleted? + content_tag :span, vendor.display_name, class: options[:class], style: 'text-decoration: line-through;' + else + link_to spree.admin_vendor_path(vendor), class: options[:class], data: { 'turbo-frame': '_top' } do + vendor.display_name + end + end + end + + def vendor_logo(vendor, options = {}) + if vendor.logo.attached? && vendor.logo.variable? + spree_image_tag( + vendor.logo, + width: options[:height], + height: options[:height], + alt: vendor.name, + loading: :lazy, + class: 'with-tip rounded-sm', + title: vendor.name + ) + end + end + + def vendors_scope + @vendors_scope ||= Spree::Vendor.accessible_by(current_ability).order(:name) + end + + def vendors_list + @vendors_list ||= vendors_scope.pluck(:name, :id) + end + + def vendor_options + if params.dig(:q, :vendor_orders_vendor_id_eq) + vendors_scope.where(id: params.dig(:q, :vendor_orders_vendor_id_eq)).pluck(:name, :id) + else + [] + end + end + + def vendor_filter_dropdown_value + case params.dig(:q, :state_eq) + when 'approved' + Spree.t('admin.vendors.approved') + when 'rejected' + Spree.t('admin.vendors.rejected') + when 'suspended' + Spree.t('admin.vendors.suspended') + when 'ready_for_review' + Spree.t('admin.vendors.ready_for_review') + when 'onboarding' + Spree.t('admin.vendors.onboarding') + when 'invited' + Spree.t('admin.vendors.invited') + when 'pending' + Spree.t('admin.vendors.pending') + else + Spree.t('admin.vendors.all') + end + end + + def vendor_status_badge(vendor = nil, options = {}) + vendor ||= current_vendor + + return if vendor.nil? + + case vendor.state.to_s + when 'approved' + title = if current_vendor.present? + "Your store is live on #{current_store.name}" + else + 'Partner is live!' + end + icon = 'check.svg' + badge = 'success' + when 'ready_for_review' + title = if current_vendor.present? + "#{current_store.name} team will review your shop shortly" + else + 'Partner completed onboarding tasks, ready for review' + end + icon = 'eye' + badge = 'ready_for_review' + when 'suspended' + title = if current_vendor.present? + "Your store is suspended. Please contact #{current_store.name} for more information." + else + 'Partner is suspended' + end + icon = 'exclamation-circle' + badge = 'warning' + when 'rejected' + title = if current_vendor.present? + "Your store was rejected. Please contact #{current_store.name} for more information." + else + 'Partner was rejected' + end + + icon = 'exclamation-circle' + badge = 'warning' + when 'invited' || 'pending' + title = 'Partner invited' + icon = 'send.svg' + else + title = if current_vendor.present? + "Complete your tasks to start selling on #{current_store.name}" + else + 'Partner needs to complete their onboarding tasks' + end + end + + icon ||= 'progress' + badge ||= 'light border' + + return if title.blank? || icon.blank? + + content_tag :span, class: "vendor-status-badge badge badge-#{badge} #{options[:class]} with-tip px-2", title: title do + icon(icon) + vendor.state.humanize.capitalize + end + end + + def ready_for_review_vendors_count + if defined?(current_store) && current_store && !current_vendor.present? + @ready_for_review_vendors_count ||= Spree::Vendor.ready_for_review.count + end end end end diff --git a/app/helpers/spree/api/vendor_helper.rb b/app/helpers/spree/api/vendor_helper.rb deleted file mode 100644 index d186b51d..00000000 --- a/app/helpers/spree/api/vendor_helper.rb +++ /dev/null @@ -1,11 +0,0 @@ -module Spree - module Api - module VendorHelper - def current_spree_vendor - if current_api_user.vendors.any? && (!current_api_user.respond_to?(:has_spree_role?) || !current_api_user.has_spree_role?(:admin)) - current_api_user.vendors.first - end - end - end - end -end diff --git a/app/helpers/spree_multi_vendor/admin/orders_filters_helper_decorator.rb b/app/helpers/spree_multi_vendor/admin/orders_filters_helper_decorator.rb new file mode 100644 index 00000000..8a14e05c --- /dev/null +++ b/app/helpers/spree_multi_vendor/admin/orders_filters_helper_decorator.rb @@ -0,0 +1,11 @@ +module SpreeMultiVendor + module Admin + module OrdersFiltersHelperDecorator + def load_vendor + @vendor = Spree::Vendor.friendly.find(params[:vendor_id]) if params[:vendor_id].present? && !current_vendor.present? + end + end + end +end + +Spree::Admin::OrdersFiltersHelper.prepend(SpreeMultiVendor::Admin::OrdersFiltersHelperDecorator) diff --git a/app/helpers/spree_multi_vendor/admin/orders_helper_decorator.rb b/app/helpers/spree_multi_vendor/admin/orders_helper_decorator.rb new file mode 100644 index 00000000..d9c4789e --- /dev/null +++ b/app/helpers/spree_multi_vendor/admin/orders_helper_decorator.rb @@ -0,0 +1,41 @@ +module SpreeMultiVendor + module Admin + module OrdersHelperDecorator + def order_summary_tax_lines_additional(order) + line_item_taxes = order.suborder? ? [] : order.all_line_item_adjustments.tax + line_item_taxes = line_item_taxes.map { |tax_adjustment| map_to_tax_line(tax_adjustment) } + + shipment_taxes = order.suborder? ? [] : order.all_shipment_adjustments.tax + shipment_taxes = shipment_taxes.map { |tax_adjustment| map_to_tax_line(tax_adjustment, for_shipment: true) } + + line_item_taxes + shipment_taxes + end + + def marketplace_commission_on_vendor_orders?(order) + order.splitted? && order.vendor_order_marketplace_commissions.any? + end + + def order_summary_marketplace_commissions(order) + marketplace_commission_on_vendor_orders?(order) ? order.vendor_order_marketplace_commissions : order.marketplace_commissions + end + + def order_summary_marketplace_commission_money(order) + if marketplace_commission_on_vendor_orders?(order) + order.vendor_orders.sum(Spree::Money.new(0, currency: order.currency)) do |vendor_order| + calculate_marketplace_commission_total(vendor_order) + end + else + calculate_marketplace_commission_total(order) + end + end + + private + + def calculate_marketplace_commission_total(order) + order.platform_fee_total_money.abs - order.platform_fee_reverse_total_money.abs + end + end + end +end + +Spree::Admin::OrdersHelper.prepend(SpreeMultiVendor::Admin::OrdersHelperDecorator) diff --git a/app/helpers/spree_multi_vendor/admin/products_helper_decorator.rb b/app/helpers/spree_multi_vendor/admin/products_helper_decorator.rb new file mode 100644 index 00000000..8a9be00e --- /dev/null +++ b/app/helpers/spree_multi_vendor/admin/products_helper_decorator.rb @@ -0,0 +1,15 @@ +module SpreeMultiVendor + module Admin + module ProductsHelperDecorator + def variant_form_stock_location_options + options_for_select(available_stock_locations_list(vendor: @product.vendor)) + end + + def product_list_filters_search_form_path + [:admin, @vendor, @search] + end + end + end +end + +Spree::Admin::ProductsHelper.prepend(SpreeMultiVendor::Admin::ProductsHelperDecorator) diff --git a/app/helpers/spree_multi_vendor/admin/stock_locations_helper_decorator.rb b/app/helpers/spree_multi_vendor/admin/stock_locations_helper_decorator.rb new file mode 100644 index 00000000..4f4be504 --- /dev/null +++ b/app/helpers/spree_multi_vendor/admin/stock_locations_helper_decorator.rb @@ -0,0 +1,28 @@ +module SpreeMultiVendor + module Admin + module StockLocationsHelperDecorator + def available_stock_locations(opts = {}) + scope = super(opts) + if opts[:vendor].present? + scope.where(vendor: opts[:vendor]) + else + scope.where(vendor_id: nil) + end + end + + def available_stock_locations_for_product(product) + if product.vendor # Admin user editing vendor's product or vendor editing his own product + product.vendor.stock_locations + else # Admin user editing his own product + available_stock_locations + end + end + + def default_stock_location_for_product(product) + product.vendor.present? ? product.vendor.default_stock_location : super(product) + end + end + end +end + +Spree::Admin::StockLocationsHelper.prepend(SpreeMultiVendor::Admin::StockLocationsHelperDecorator) diff --git a/app/helpers/spree_multi_vendor/spree/api/api_helpers_decorator.rb b/app/helpers/spree_multi_vendor/spree/api/api_helpers_decorator.rb deleted file mode 100644 index bd942770..00000000 --- a/app/helpers/spree_multi_vendor/spree/api/api_helpers_decorator.rb +++ /dev/null @@ -1,19 +0,0 @@ -module SpreeMultiVendor - module Spree - module Api - module ApiHelpersDecorator - def self.prepended(base) - base::ATTRIBUTES.push(:vendor_attributes) - - base.mattr_reader *base::ATTRIBUTES - - base.user_attributes << :vendor_ids - end - - @@vendor_attributes = [:id, :name, :slug, :state, :about_us, :contact_us] - end - end - end -end - -::Spree::Api::ApiHelpers.prepend(SpreeMultiVendor::Spree::Api::ApiHelpersDecorator) if SpreeMultiVendor::Engine.api_v1_available? diff --git a/app/javascript/spree_multi_vendor/application.js b/app/javascript/spree_multi_vendor/application.js new file mode 100644 index 00000000..99db8b29 --- /dev/null +++ b/app/javascript/spree_multi_vendor/application.js @@ -0,0 +1,16 @@ +import '@hotwired/turbo-rails' +import { Application } from '@hotwired/stimulus' + +let application + +if (typeof window.Stimulus === "undefined") { + application = Application.start() + application.debug = false + window.Stimulus = application +} else { + application = window.Stimulus +} + +import SpreeMultiVendorController from 'spree_multi_vendor/controllers/spree_multi_vendor_controller' + +application.register('spree_multi_vendor', SpreeMultiVendorController) \ No newline at end of file diff --git a/app/javascript/spree_multi_vendor/controllers/spree_multi_vendor_controller.js b/app/javascript/spree_multi_vendor/controllers/spree_multi_vendor_controller.js new file mode 100644 index 00000000..22b66c93 --- /dev/null +++ b/app/javascript/spree_multi_vendor/controllers/spree_multi_vendor_controller.js @@ -0,0 +1,7 @@ +import { Controller } from '@hotwired/stimulus' + +export default class extends Controller { + connect() { + console.log('Hello, SpreeMultiVendor!') + } +} \ No newline at end of file diff --git a/app/jobs/spree_multi_vendor/base_job.rb b/app/jobs/spree_multi_vendor/base_job.rb new file mode 100644 index 00000000..2208cca1 --- /dev/null +++ b/app/jobs/spree_multi_vendor/base_job.rb @@ -0,0 +1,5 @@ +module SpreeMultiVendor + class BaseJob < Spree::BaseJob + queue_as SpreeMultiVendor.queue + end +end diff --git a/app/jobs/spree_multi_vendor/orders/split_by_vendor_job.rb b/app/jobs/spree_multi_vendor/orders/split_by_vendor_job.rb new file mode 100644 index 00000000..9317493f --- /dev/null +++ b/app/jobs/spree_multi_vendor/orders/split_by_vendor_job.rb @@ -0,0 +1,10 @@ +module SpreeMultiVendor + module Orders + class SplitByVendorJob < ::SpreeMultiVendor::BaseJob + def perform(order_id) + order = Spree::Order.find(order_id) + Spree::Orders::SplitByVendor.call(order: order) + end + end + end +end diff --git a/app/jobs/spree_multi_vendor/vendors/activate_products_job.rb b/app/jobs/spree_multi_vendor/vendors/activate_products_job.rb new file mode 100644 index 00000000..33fad6b5 --- /dev/null +++ b/app/jobs/spree_multi_vendor/vendors/activate_products_job.rb @@ -0,0 +1,16 @@ +module SpreeMultiVendor + module Vendors + class ActivateProductsJob < ::SpreeMultiVendor::BaseJob + def perform(vendor_id) + vendor = Spree::Vendor.find(vendor_id) + + paused_products = vendor.products.paused + draft_products = vendor.products.draft + + paused_products.or(draft_products).find_in_batches do |group| + group.each(&:activate!) + end + end + end + end +end diff --git a/app/jobs/spree_multi_vendor/vendors/archive_products_job.rb b/app/jobs/spree_multi_vendor/vendors/archive_products_job.rb new file mode 100644 index 00000000..f3798824 --- /dev/null +++ b/app/jobs/spree_multi_vendor/vendors/archive_products_job.rb @@ -0,0 +1,10 @@ +module SpreeMultiVendor + module Vendors + class ArchiveProductsJob < ::SpreeMultiVendor::BaseJob + def perform(vendor_id) + vendor = Spree::Vendor.find(vendor_id) + vendor.products.archivable.find_each(&:archive) + end + end + end +end diff --git a/app/jobs/spree_multi_vendor/vendors/pause_products_job.rb b/app/jobs/spree_multi_vendor/vendors/pause_products_job.rb new file mode 100644 index 00000000..051b3e01 --- /dev/null +++ b/app/jobs/spree_multi_vendor/vendors/pause_products_job.rb @@ -0,0 +1,13 @@ +module SpreeMultiVendor + module Vendors + class PauseProductsJob < ::SpreeMultiVendor::BaseJob + def perform(vendor_id) + vendor = Spree::Vendor.find(vendor_id) + + vendor.products.active.find_in_batches do |group| + group.each(&:pause!) + end + end + end + end +end diff --git a/app/mailers/spree/vendor_mailer.rb b/app/mailers/spree/vendor_mailer.rb index 07bddd3d..cdc614c7 100644 --- a/app/mailers/spree/vendor_mailer.rb +++ b/app/mailers/spree/vendor_mailer.rb @@ -1,15 +1,50 @@ module Spree - class VendorMailer < ::Spree::BaseMailer - def vendor_notification_email(order_id, vendor_id) - @vendor = Spree::Vendor.find(vendor_id) - return unless @vendor.notification_email.present? - - @order = Spree::Order.find(order_id) - @line_items = @order.line_items.for_vendor(@vendor) - @subtotal = @order.vendor_subtotal(@vendor) - @total = @order.vendor_total(@vendor) - subject = "#{Spree::Store.current.name} #{Spree.t('order_mailer.vendor_notification_email.subject')} ##{@order.number}" - mail(to: @vendor.notification_email, from: from_address, subject: subject) + class VendorMailer < Spree::BaseMailer + helper ActionView::Helpers::SanitizeHelper + include ApplicationHelper + + def vendor_suspended(vendor) + @vendor = vendor + subject = "#{current_store.name} #{Spree.t('vendor_mailer.suspended_email.subject', store_name: current_store.name, vendor_name: vendor.name)}" + mail(to: vendor_owners_emails, subject: subject, from: from_address, reply_to: reply_to_address, store_url: current_store.url) + end + + def vendor_approved + @vendor = params[:vendor] + subject = "#{Spree.t('vendor_mailer.approved_email_to_marketplace_owners.message', store_name: current_store.name, vendor_name: @vendor.name).to_s}" + mail(to: params[:recipient].email, subject: subject, from: from_address, reply_to: reply_to_address, store_url: current_store.url) + end + + def vendor_rejected(vendor) + @vendor = vendor + subject = "#{current_store.name} #{Spree.t('vendor_mailer.rejected_email.subject', store_name: current_store.name, vendor_name: vendor.name)}" + mail(to: vendor_owners_emails, subject: subject, from: from_address, reply_to: reply_to_address, store_url: current_store.url) + end + + def vendor_onboarding_started + @vendor = params[:vendor] + subject = Spree.t('vendor_mailer.onboarding_started.subject', vendor_name: @vendor.name) + mail(to: params[:recipient].email, subject: subject, from: from_address, reply_to: reply_to_address, store_url: current_store.url) + end + + def vendor_onboarding_completed + @vendor = params[:vendor] + subject = Spree.t('vendor_mailer.onboarding_completed.subject', vendor_name: @vendor.name) + mail(to: params[:recipient].email, subject: subject, from: from_address, reply_to: reply_to_address, store_url: current_store.url) + end + + private + + def current_store + @current_store ||= @vendor.store + end + + def vendor_owners_emails + (@vendor.users.pluck(:email) << @vendor.contact_person_email).compact.uniq(&:downcase) + end + + def marketplace_owners_emails + @marketplace_owners_emails ||= current_store.users.pluck(:email) end end end diff --git a/app/mailers/spree_multi_vendor/order_mailer_decorator.rb b/app/mailers/spree_multi_vendor/order_mailer_decorator.rb new file mode 100644 index 00000000..3dee8086 --- /dev/null +++ b/app/mailers/spree_multi_vendor/order_mailer_decorator.rb @@ -0,0 +1,21 @@ +module SpreeMultiVendor + module OrderMailerDecorator + def splitted_order_confirm_email(order, resend = false) + @order = order.respond_to?(:id) ? order : Spree::Order.find(order) + current_store = @order.store + subject = (resend ? "[#{Spree.t(:resend).upcase}] " : '') + subject += "#{current_store.name} #{Spree.t('order_mailer.confirm_email.subject')} ##{@order.number}" + mail(to: @order.email, from: from_address, subject: subject, store_url: current_store.url_or_custom_domain, reply_to: reply_to_address) + end + + def vendor_order_confirm_email + @order = params[:order] + @store = @order.store + @url = Spree::Core::Engine.routes.url_helpers.edit_admin_order_url(@order, host: @store.url) + + mail(to: params[:recipient].email, from: from_address, subject: 'New Order Received', reply_to: reply_to_address) + end + end +end + +Spree::OrderMailer.prepend(SpreeMultiVendor::OrderMailerDecorator) diff --git a/app/models/.gitkeep b/app/models/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/app/models/concerns/spree/vendor_concern.rb b/app/models/concerns/spree/vendor_concern.rb index 9b5c317f..00c64e2e 100644 --- a/app/models/concerns/spree/vendor_concern.rb +++ b/app/models/concerns/spree/vendor_concern.rb @@ -3,9 +3,15 @@ module VendorConcern extend ActiveSupport::Concern included do - belongs_to :vendor, class_name: 'Spree::Vendor', touch: true, optional: true + belongs_to :vendor, -> { with_deleted }, class_name: 'Spree::Vendor', touch: false, optional: true scope :with_vendor, ->(vendor_id) { where(vendor_id: vendor_id) } + scope :has_vendor, -> { where.not(vendor_id: nil) } + scope :without_vendor, -> { where(vendor_id: nil) } + end + + def has_vendor?(vendor) + self.vendor == vendor end end end diff --git a/app/models/concerns/spree/vendors/onboarding.rb b/app/models/concerns/spree/vendors/onboarding.rb new file mode 100644 index 00000000..8c578a12 --- /dev/null +++ b/app/models/concerns/spree/vendors/onboarding.rb @@ -0,0 +1,68 @@ +module Spree + module Vendors + module Onboarding + extend ActiveSupport::Concern + + included do + after_update :update_onboarding_status + after_touch :update_onboarding_status + end + + def onboarding_tasks_list + [].tap do |task| + task << :connect_shop if defined?(Spree::ExternalStore) + task << :add_billing_address + task << :connect_stripe if defined?(SpreeStripe::Account) && store.respond_to?(:stripe_gateway) && store.stripe_gateway.present? + task << :add_returns_address + task << :set_shipping_rates + task << :add_policies + end + end + + def onboarding_task_done?(task) + case task + when :connect_shop + respond_to?(:external_store) && external_store&.active? || prefers_not_using_external_store? + when :connect_stripe + respond_to?(:stripe_account) && stripe_account&.active? + when :set_shipping_rates + preferred_shipping_rates_confirmed + when :add_policies + policies.all?(&:with_body?) + when :add_returns_address + returns_address.present? && returns_address.persisted? + when :add_billing_address + billing_address.present? && billing_address.persisted? + end + end + + def onboarding_task_pending?(task) + case task + when :connect_stripe + respond_to?(:stripe_account) && stripe_account.present? && stripe_account.pending? + end + end + + def onboarding_tasks_total + @onboarding_tasks_total = onboarding_tasks_list.count + end + + def onboarding_tasks_done + @onboarding_tasks_done = onboarding_tasks_list.select { |task| onboarding_task_done?(task) }.count + end + + def onboarding_completed? + onboarding_tasks_done == onboarding_tasks_total + end + alias setup_completed? onboarding_completed? + + def update_onboarding_status + complete_onboarding if onboarding_completed? && %[invited onboarding].include?(state) && !deleted? + end + + def onboarding_percentage + (onboarding_tasks_done / onboarding_tasks_total.to_f * 100).to_i + end + end + end +end diff --git a/app/models/spree/billing_address.rb b/app/models/spree/billing_address.rb new file mode 100644 index 00000000..91c7eba6 --- /dev/null +++ b/app/models/spree/billing_address.rb @@ -0,0 +1,11 @@ +module Spree + class BillingAddress < Address + def require_name? + false + end + + def show_company_address_field? + true + end + end +end diff --git a/app/models/spree/calculators/returns/default_refund_tax_amount.rb b/app/models/spree/calculators/returns/default_refund_tax_amount.rb new file mode 100644 index 00000000..6bb86e61 --- /dev/null +++ b/app/models/spree/calculators/returns/default_refund_tax_amount.rb @@ -0,0 +1,27 @@ +module Spree + module Calculators::Returns + class DefaultRefundTaxAmount < ReturnsCalculator + def compute(return_item, type:) + return 0.to_d if return_item.exchange_requested? + + line_item = return_item.line_item + + case type + when :additional + calculate_tax_amount(return_item, line_item.additional_tax_total) + when :included + calculate_tax_amount(return_item, line_item.included_tax_total) + end + end + + private + + def calculate_tax_amount(return_item, line_item_tax_amount) + line_item = return_item.line_item + tax_amount_per_unit = line_item_tax_amount / line_item.quantity + + return_item.return_quantity * tax_amount_per_unit + end + end + end +end diff --git a/app/models/spree/calculators/returns/platform_refund_amount.rb b/app/models/spree/calculators/returns/platform_refund_amount.rb new file mode 100644 index 00000000..b4c5b9f2 --- /dev/null +++ b/app/models/spree/calculators/returns/platform_refund_amount.rb @@ -0,0 +1,16 @@ +module Spree + module Calculators::Returns + class PlatformRefundAmount < ReturnsCalculator + def compute(return_item) + return 0.to_d if return_item.exchange_requested? + + line_item = return_item.line_item + promo_per_item = line_item.promo_total.abs / line_item.quantity + included_tax_per_item = line_item.included_tax_total / line_item.quantity + refund_amount_per_item = line_item.price - promo_per_item - included_tax_per_item - line_item.platform_fee_per_unit + + return_item.return_quantity * refund_amount_per_item + end + end + end +end diff --git a/app/models/spree/marketplace_commission.rb b/app/models/spree/marketplace_commission.rb new file mode 100644 index 00000000..33f3d775 --- /dev/null +++ b/app/models/spree/marketplace_commission.rb @@ -0,0 +1,62 @@ +module Spree + class MarketplaceCommission < Spree.base_class + self.table_name = 'spree_platform_fees' + + extend DisplayMoney + money_methods :amount, :discount, :amount_before_discount + + belongs_to :order, class_name: 'Spree::Order' + belongs_to :feeable, polymorphic: true + + validates :amount, presence: true + validates :order, presence: true + + ITEM_LABEL = 'Item commission' + REVERSE_ORDER_LABEL = 'Reverse order commission' + REVERSE_ITEM_LABEL = 'Reverse item commission' + + scope :active, -> { where(active: true) } + scope :positive, -> { where(label: ITEM_LABEL) } + scope :reversed, -> { where(label: [REVERSE_ITEM_LABEL, REVERSE_ORDER_LABEL]) } + + alias amount_money display_amount + delegate :currency, to: :order + + def discounted? + discount.positive? + end + + def reversed? + label.in?([REVERSE_ITEM_LABEL, REVERSE_ORDER_LABEL]) + end + + def item_name + label == REVERSE_ORDER_LABEL ? order.number : feeable.try(:name) + end + + def currency + order.try(:currency) + end + + def discount + metadata = feeable.try(:private_metadata) || {} + metadata.fetch(:platform_fee_discount, 0).to_d + end + + def amount_before_discount + amount + discount + end + + def rate_after_discount + return rate if discount.zero? + + amount_before_discount = amount + discount + (amount / amount_before_discount) * rate + end + + def display_amount_with_sign(before_discount: false) + amount_to_display = before_discount ? display_amount_before_discount : display_amount + reversed? ? -amount_to_display : amount_to_display + end + end +end diff --git a/app/models/spree/order/emails.rb b/app/models/spree/order/emails.rb new file mode 100644 index 00000000..352a53e5 --- /dev/null +++ b/app/models/spree/order/emails.rb @@ -0,0 +1,45 @@ +module Spree + class Order < Spree.base_class + module Emails + def deliver_order_confirmation_email + return if line_items.where.not(vendor_id: nil).any? + + unless completed? + errors.add(:base, Spree.t(:order_email_resent_error)) + return + end + + OrderMailer.confirm_email(id).deliver_later if send_consumer_transactional_emails? + + update_column(:confirmation_delivered, true) unless confirmation_delivered + end + + def deliver_splitted_order_confirmation_email + return unless send_consumer_transactional_emails? + + Spree::OrderMailer.splitted_order_confirm_email(id).deliver_later + update_column(:confirmation_delivered, true) + end + + def deliver_store_owner_order_notification_email? + !store.test_mode? && store.new_order_notifications_email.present? && !store_owner_notification_delivered? && send_consumer_transactional_emails? + end + + def deliver_store_owner_order_notification_email + OrderMailer.store_owner_notification_email(id).deliver_later + update_column(:store_owner_notification_delivered, true) + end + + def send_cancel_email + OrderMailer.cancel_email(id).deliver_later if send_consumer_transactional_emails? + + end + + private + + def send_consumer_transactional_emails? + store.prefers_send_consumer_transactional_emails? + end + end + end +end diff --git a/app/models/spree/order_commission.rb b/app/models/spree/order_commission.rb deleted file mode 100644 index 0a972e7a..00000000 --- a/app/models/spree/order_commission.rb +++ /dev/null @@ -1,11 +0,0 @@ -module Spree - class OrderCommission < Base - belongs_to :order, required: Spree.version.to_f >= 3.5 - belongs_to :vendor, required: Spree.version.to_f >= 3.5 - - validates :order, :vendor, presence: true - validates :vendor_id, uniqueness: { scope: :order_id } - - scope :for_vendor, ->(vendor) { where(vendor_id: vendor.id) } - end -end diff --git a/app/models/spree/reimbursement_type/original_payment.rb b/app/models/spree/reimbursement_type/original_payment.rb new file mode 100644 index 00000000..61e2b4cd --- /dev/null +++ b/app/models/spree/reimbursement_type/original_payment.rb @@ -0,0 +1,13 @@ +class Spree::ReimbursementType::OriginalPayment < Spree::ReimbursementType + extend Spree::ReimbursementType::ReimbursementHelpers + + class << self + def reimburse(reimbursement, _return_items, simulate) + unpaid_amount = reimbursement.total + payments = reimbursement.order.payments.completed + + reimbursement_list, _unpaid_amount = create_refunds(reimbursement, payments, unpaid_amount, simulate) + reimbursement_list + end + end +end diff --git a/app/models/spree/reimbursement_type/reimbursement_helpers_decorator.rb b/app/models/spree/reimbursement_type/reimbursement_helpers_decorator.rb new file mode 100644 index 00000000..f0bb00d6 --- /dev/null +++ b/app/models/spree/reimbursement_type/reimbursement_helpers_decorator.rb @@ -0,0 +1,19 @@ +module Spree + module ReimbursementType::ReimbursementHelpersDecorator + private + + def create_refund(reimbursement, payment, amount, simulate) + refund = reimbursement.refunds.build( + payment: payment, + amount: amount, + reason: Spree::RefundReason.return_processing_reason, + refunder: reimbursement.performed_by + ) + + simulate ? refund.readonly! : refund.save! + refund + end + end +end + +Spree::ReimbursementType::ReimbursementHelpers.prepend(Spree::ReimbursementType::ReimbursementHelpersDecorator) diff --git a/app/models/spree/returns_address.rb b/app/models/spree/returns_address.rb new file mode 100644 index 00000000..a88032ce --- /dev/null +++ b/app/models/spree/returns_address.rb @@ -0,0 +1,11 @@ +module Spree + class ReturnsAddress < Address + def require_name? + false + end + + def show_company_address_field? + true + end + end +end diff --git a/app/models/spree/vendor.rb b/app/models/spree/vendor.rb index f650a38e..3b05742d 100644 --- a/app/models/spree/vendor.rb +++ b/app/models/spree/vendor.rb @@ -1,72 +1,346 @@ module Spree - class Vendor < Spree::Base + class Vendor < Spree.base_class extend FriendlyId - include TranslatableResource - include TranslatableResourceSlug + friendly_id :name, use: :slugged - TRANSLATABLE_FIELDS = %i[name about_us contact_us slug].freeze - translates(*TRANSLATABLE_FIELDS) + include Spree::Metadata + include Spree::Webhooks::HasWebhooks + include Spree::Vendors::Onboarding + include Spree::UserManagement + include Spree::Metafields + include Spree::TranslatableResource + include PgSearch::Model if defined?(PgSearch) + + audited only: %i[name state billing_email contact_person_email] if defined?(Audited) + + DEFAULT_VENDOR_ROLE = 'vendor' + + # + # Magic ✨ + # acts_as_paranoid - acts_as_list column: :priority - friendly_id :name, use: %i[slugged history] - validates :name, - presence: true, - uniqueness: { case_sensitive: false } + # + # Attachments + # + has_one_attached :logo, service: Spree.public_storage_service_name + has_one_attached :square_logo, service: Spree.public_storage_service_name + has_one_attached :cover_photo, service: Spree.public_storage_service_name - validates :slug, uniqueness: true - validates_associated :image + # + # Translations + # + TRANSLATABLE_FIELDS = %i[about].freeze + translates(*TRANSLATABLE_FIELDS, column_fallback: !Spree.always_use_translations?) - validates :notification_email, email: true, allow_blank: true + # + # Rich Texts + # + translates :about, backend: :action_text - with_options dependent: :destroy do - has_one :image, as: :viewable, dependent: :destroy, class_name: 'Spree::VendorImage' - has_many :commissions, class_name: 'Spree::OrderCommission' - has_many :vendor_users + # + # Preferences + # + preference :shipping_rates_confirmed, :boolean, default: false - SpreeMultiVendor::Config[:vendorized_models].uniq.compact.each do |model| - has_many model.pluralize.to_sym - end - end + # + # Virtual attributes + # + attribute :invited_by_email + attribute :skip_global_onboarding_tasks, default: false, type: :boolean - has_many :users, through: :vendor_users, class_name: Spree.user_class.to_s + # + # Callbacks + # + before_validation :set_platform_fee_from_store, on: :create + after_create :add_contact_person_to_users after_create :create_stock_location + after_create :create_default_policies + after_create :set_state_to_invited after_update :update_stock_location_names + before_destroy :ensure_can_be_deleted + + # + # Validations + # + validates :name, presence: true + validates :contact_person_email, presence: true, + uniqueness: { scope: spree_base_uniqueness_scope }, + email: true, + if: :skip_contact_person_email_validation + validate :contact_person_cannot_be_store_admin + validates :logo, :square_logo, :cover_photo, content_type: Rails.application.config.active_storage.web_image_content_types + + # + # Associations + # + belongs_to :invited_by, class_name: Spree.admin_user_class.to_s, foreign_key: :invited_by_id + belongs_to :returns_address, class_name: 'Spree::ReturnsAddress', dependent: :destroy, autosave: true + belongs_to :billing_address, class_name: 'Spree::BillingAddress', dependent: :destroy, autosave: true + + accepts_nested_attributes_for :returns_address, :billing_address + + has_many :products, class_name: 'Spree::Product', dependent: :destroy_async + has_many :active_products, -> { active }, class_name: 'Spree::Product' + has_many :variants, class_name: 'Spree::Variant', through: :products, source: :variants_including_master + has_many :classifications, class_name: 'Spree::Classification', through: :products, source: :classifications + has_many :taxons, class_name: 'Spree::Taxon', through: :classifications + has_many :digital_links, class_name: 'Spree::DigitalLink', through: :variants + has_many :policies, class_name: 'Spree::Policy', dependent: :destroy, as: :owner + + has_many :orders, class_name: 'Spree::Order' + has_many :line_items, class_name: 'Spree::Shipment', through: :orders + has_many :shipments, class_name: 'Spree::Shipment', through: :orders + has_many :payments, class_name: 'Spree::Payment', through: :orders + has_many :return_authorizations, class_name: 'Spree::ReturnAuthorization', through: :orders + has_many :reimbursements, class_name: 'Spree::Reimbursement', through: :orders + has_many :adjustments, class_name: 'Spree::Adjustment', through: :orders + has_many :refunds, class_name: 'Spree::Refund', through: :payments + + has_many :stock_locations, class_name: 'Spree::StockLocation', dependent: :destroy + has_many :stock_items, class_name: 'Spree::StockItem', through: :stock_locations + has_many :shipping_methods, class_name: 'Spree::ShippingMethod', dependent: :destroy + + # + # State machine + # state_machine :state, initial: :pending do - event :activate do - transition to: :active + event :invite do + transition from: :pending, to: :invited + end + event :cancel do + transition from: :invited, to: :canceled + end + event :start_onboarding do + transition from: [:pending, :invited], to: :onboarding + end + event :complete_onboarding do + transition from: [:pending, :invited, :onboarding], to: :ready_for_review, if: lambda { |vendor| + vendor.onboarding_completed? + } + end + event :approve do + transition from: [:pending, :invited, :onboarding, :suspended, :ready_for_review, :rejected], to: :approved + end + event :reject do + transition from: [:pending, :invited, :onboarding, :suspended, :ready_for_review], to: :rejected + end + event :suspend do + transition from: [:pending, :invited, :onboarding, :suspended, :ready_for_review, :approved, :rejected], to: :suspended + end + + after_transition to: :suspended, do: :archive_products + after_transition to: [:rejected, :suspended], do: :send_state_transition_email + after_transition to: :approved, do: :after_approved + after_transition to: :onboarding, do: %i[after_onboarding_started] + after_transition to: :ready_for_review, do: :after_onboarding_completed + after_transition to: :canceled, do: ->(vendor) { vendor.destroy } + end + + # + # Scopes + # + scope :invited, -> { where(state: 'invited') } + scope :onboarding, -> { where(state: 'onboarding') } + scope :approved, -> { where(state: 'approved') } + scope :ready_for_review, -> { where(state: 'ready_for_review') } + scope :with_access_to_ui, -> { where.not(state: 'pending') } # even rejected or suspended vendors should have access to UI + + if defined?(PgSearch) + pg_search_scope :search_by_name, against: :name + else + scope :search_by_name, ->(name) { where('name ILIKE ?', "%#{name}%") } + end + + # + # Ransack configuration + # + self.whitelisted_ransackable_attributes = %w[name state products_count sales_total commission_total created_at] + self.whitelisted_ransackable_scopes = %w[invited accepted onboarding approved ready_for_review] + self.whitelisted_ransackable_associations = %w[store] + + # + # Money methods 💸 + # + extend DisplayMoney + money_methods :sales_total, :commission_total + + def can_be_deleted? + orders.not_shipped.empty? + end + + def not_approved? + state != 'approved' + end + + def can_approve? + super && onboarding_completed? + end + + def skip_contact_person_email_validation + false + end + + def default_stock_location + @default_stock_location ||= Rails.cache.fetch("vendor-default-stock-location/#{cache_key_with_version}") do + stock_locations.active.order_default.first || ActiveRecord::Base.connected_to(role: :writing) { create_stock_location } end + end + + def weight_unit + store.preferred_weight_unit + end - event :block do - transition to: :blocked + def stripe_payouts_schedule + case payouts_schedule_interval + when 'daily' + { interval: 'daily' } + when 'weekly' + { interval: 'weekly', weekly_anchor: 'monday' } + when 'manual' + { interval: 'manual' } + else + { interval: 'monthly', monthly_anchor: 1 } end end - scope :active, -> { where(state: 'active') } + def contact_person + @contact_person ||= Spree.admin_user_class.find_by(email: contact_person_email) + end + + def display_name + name + end + + def email + billing_email || contact_person_email + end + + def store + if respond_to?(:tenant) + tenant.default_store + else + Spree::Store.current + end + end - self.whitelisted_ransackable_attributes = %w[name state] + def default_user_role + Spree::Role.find_or_create_by!(name: DEFAULT_VENDOR_ROLE) + end - def update_notification_email(email) - update(notification_email: email) + def returns_policy + policies.find_by(name: Spree.t('returns_policy')) end private + def ensure_can_be_deleted + return if can_be_deleted? + + errors.add(:base, :cannot_destroy_vendor) + throw(:abort) + end + def create_stock_location - stock_locations.where(name: name, country: Spree::Store.default.default_country).first_or_create! + stock_location = stock_locations.where(name: name).first_or_initialize + stock_location.country ||= store.default_country + stock_location.save! + stock_location end - def should_generate_new_friendly_id? - slug.blank? || name_changed? + def create_default_policies + SpreeMultiVendor::Config[:default_policies].each do |policy| + vendor_policy = policies.find_or_initialize_by(name: Spree.t(policy)) + vendor_policy.save! + end end def update_stock_location_names - if (Spree.version.to_f < 3.5 && self.name_changed?) || (Spree.version.to_f >= 3.5 && saved_changes&.include?(:name)) - stock_locations.update_all({ name: name }) + return unless saved_changes&.include?(:name) + + stock_locations.each do |stock_location| + stock_location.update(name: name) + end + end + + def add_contact_person_to_users + # user exists, adding to the resource + if contact_person.present? && contact_person.persisted? + add_user(contact_person, default_user_role) + else + inviter = invited_by || store.users.first + + return if inviter.blank? + + # we need to invite the contact person + invitations.create!( + inviter: inviter, + email: contact_person_email, + role: default_user_role + ) + end + end + + def set_state_to_invited + return if invited_by_email == false + + invite + end + + def set_platform_fee_from_store + self.platform_fee ||= store&.preferred_platform_fee + end + + def send_state_transition_email(transition) + Spree::VendorMailer.send("vendor_#{transition.to}", self).deliver_later + end + + def after_onboarding_started + # notify store admins + store.users.each do |admin| + Spree::VendorMailer.with(vendor: self, recipient: admin).vendor_onboarding_started.deliver_later end end + + def after_onboarding_completed + # notify store admins + store.users.each do |admin| + Spree::VendorMailer.with(vendor: self, recipient: admin).vendor_onboarding_completed.deliver_later + end + end + + def after_approved + # notify vendor users + users.each do |owner| + Spree::VendorMailer.with(vendor: self, recipient: owner).vendor_approved.deliver_later + end + + # notify store admins + store.users.each do |admin| + Spree::VendorMailer.with(vendor: self, recipient: admin).vendor_approved.deliver_later + end + end + + def archive_products + SpreeMultiVendor::Vendors::ArchiveProductsJob.perform_later(id) + end + + def pause_products + SpreeMultiVendor::Vendors::PauseProductsJob.perform_later(id) + end + + def activate_products + SpreeMultiVendor::Vendors::ActivateProductsJob.perform_later(id) + end + + def contact_person_cannot_be_store_admin + return if contact_person_email.blank? + return unless store.users.find_by(email: contact_person_email.downcase).present? + + errors.add(:contact_person_email, :cannot_be_store_admin) + end end end diff --git a/app/models/spree/vendor_ability.rb b/app/models/spree/vendor_ability.rb deleted file mode 100644 index 169faaaf..00000000 --- a/app/models/spree/vendor_ability.rb +++ /dev/null @@ -1,155 +0,0 @@ -class Spree::VendorAbility - include CanCan::Ability - - def initialize(user) - @vendor_ids = user.vendors.active.ids - - if @vendor_ids.any? - apply_classifications_permissions - apply_order_permissions - apply_image_permissions - apply_option_type_permissions - apply_price_permissions - apply_product_option_type_permissions - apply_product_permissions - apply_product_properties_permissions - apply_properties_permissions - apply_prototypes_permissions - apply_shipment_permissions - apply_shipping_methods_permissions - apply_stock_permissions - apply_stock_item_permissions - apply_stock_location_permissions - apply_stock_movement_permissions - apply_variant_permissions - apply_vendor_permissions - apply_vendor_settings_permissions - apply_state_changes_permissions - end - end - - protected - - def apply_classifications_permissions - can :manage, Spree::Classification, product: { vendor_id: @vendor_ids } - end - - def apply_order_permissions - cannot :create, Spree::Order - - order_scope = if ::Spree::Order.reflect_on_association(:vendor) - { vendor_id: @vendor_ids } - elsif ::Spree::LineItem.reflect_on_association(:vendor) - { line_items: { vendor_id: @vendor_ids } } - elsif ::Spree::Product.reflect_on_association(:vendor) - { line_items: { product: { vendor_id: @vendor_ids } } } - elsif ::Spree::Variant.reflect_on_association(:vendor) - { line_items: { variant: { vendor_id: @vendor_ids } } } - end - - if order_scope.present? - can %i[admin show index edit update cart], Spree::Order, order_scope.merge(state: 'complete') - else - cannot_display_model(Spree::Order) - end - end - - def apply_image_permissions - can :create, Spree::Image - - can [:manage, :modify], Spree::Image, ['viewable_type = ?', 'Spree::Variant'] do |image| - vendor_id = image.viewable.try(:vendor_id) - vendor_id.present? && @vendor_ids.include?(vendor_id) - end - end - - def apply_option_type_permissions - can :display, Spree::OptionType - can :display, Spree::OptionValue - end - - def apply_price_permissions - can :modify, Spree::Price, variant: { vendor_id: @vendor_ids } - end - - def apply_product_option_type_permissions - can :manage, Spree::ProductOptionType, product: { vendor_id: @vendor_ids } - can :create, Spree::ProductOptionType - can :manage, Spree::OptionValueVariant, variant: { vendor_id: @vendor_ids } - can :create, Spree::OptionValueVariant - end - - def apply_product_permissions - cannot_display_model(Spree::Product) - - return unless Spree::Product.reflect_on_association(:vendor) - - can :manage, Spree::Product, vendor_id: @vendor_ids - can :create, Spree::Product - end - - def apply_properties_permissions - can :display, Spree::Property - end - - def apply_product_properties_permissions - can :manage, Spree::ProductProperty, product: { vendor_id: @vendor_ids } - can :create, Spree::ProductProperty - end - - def apply_prototypes_permissions - can [:read, :admin], Spree::Prototype - end - - def apply_shipment_permissions - can :update, Spree::Shipment, inventory_units: { line_item: { product: { vendor_id: @vendor_ids } } } - end - - def apply_shipping_methods_permissions - can :manage, Spree::ShippingMethod, vendor_id: @vendor_ids - can :create, Spree::ShippingMethod - end - - def apply_stock_permissions - can :admin, Spree::Stock - end - - def apply_stock_item_permissions - can [:admin, :modify, :read], Spree::StockItem, stock_location: { vendor_id: @vendor_ids } - end - - def apply_stock_location_permissions - can :manage, Spree::StockLocation, vendor_id: @vendor_ids - can :create, Spree::StockLocation - end - - def apply_stock_movement_permissions - can :create, Spree::StockMovement - can :manage, Spree::StockMovement, stock_item: { stock_location: { vendor_id: @vendor_ids } } - end - - def apply_variant_permissions - cannot_display_model(Spree::Variant) - can :manage, Spree::Variant, vendor_id: @vendor_ids - can :create, Spree::Variant - end - - def apply_vendor_permissions - can :manage, Spree::Vendor, id: @vendor_ids - cannot :create, Spree::Vendor - end - - def apply_vendor_settings_permissions - can :manage, :vendor_settings - end - - def apply_state_changes_permissions - can [:admin, :index], Spree::StateChange do |state_change| - (@vendor_ids & state_change.user.vendor_ids).any? - end - end - - def cannot_display_model(model) - Spree.version.to_f < 4.0 ? (cannot :display, model) : (cannot :read, model) - end -end diff --git a/app/models/spree/vendor_image.rb b/app/models/spree/vendor_image.rb deleted file mode 100644 index 38907119..00000000 --- a/app/models/spree/vendor_image.rb +++ /dev/null @@ -1,18 +0,0 @@ -module Spree - class VendorImage < Asset - include Spree::Image::Configuration::ActiveStorage - include Rails.application.routes.url_helpers - - def styles - self.class.styles.map do |_, size| - width, height = size[/(\d+)x(\d+)/].split('x') - - { - url: polymorphic_path(attachment.variant(resize: size), only_path: true), - width: width, - height: height - } - end - end - end -end diff --git a/app/models/spree/vendor_order_totals.rb b/app/models/spree/vendor_order_totals.rb deleted file mode 100644 index 53ac32cc..00000000 --- a/app/models/spree/vendor_order_totals.rb +++ /dev/null @@ -1,28 +0,0 @@ -module Spree - class VendorOrderTotals - include ActiveModel::Model - - METHOD_NAMES = %w[ - total ship_total subtotal included_tax_total additional_tax_total promo_total - pre_tax_item_amount pre_tax_ship_amount pre_tax_total - ].freeze - - attr_accessor :vendor, :order - - delegate :id, :name, to: :vendor - - METHOD_NAMES.each do |method_name| - define_method(method_name) do - order.send("vendor_#{method_name}", vendor) - end - - define_method("display_#{method_name}") do - order.send("display_vendor_#{method_name}", vendor) - end - end - - def item_count - order.vendor_item_count(vendor) - end - end -end diff --git a/app/models/spree/vendor_user.rb b/app/models/spree/vendor_user.rb deleted file mode 100644 index eed09d28..00000000 --- a/app/models/spree/vendor_user.rb +++ /dev/null @@ -1,16 +0,0 @@ -module Spree - class VendorUser < Spree::Base - belongs_to :vendor, class_name: 'Spree::Vendor', required: Spree.version.to_f >= 3.5 - belongs_to :user, class_name: Spree.user_class.name, required: Spree.version.to_f >= 3.5, touch: true - - validates :vendor_id, uniqueness: { scope: :user_id } - - before_save :set_vendor_notification_email - - def set_vendor_notification_email - return if vendor&.notification_email.present? || !vendor&.users&.empty? - - vendor.update_notification_email(user.email) - end - end -end diff --git a/app/models/spree_multi_vendor/adjustable/adjustments_updater_decorator.rb b/app/models/spree_multi_vendor/adjustable/adjustments_updater_decorator.rb new file mode 100644 index 00000000..3f1e1d82 --- /dev/null +++ b/app/models/spree_multi_vendor/adjustable/adjustments_updater_decorator.rb @@ -0,0 +1,19 @@ +module SpreeMultiVendor + module Adjustable + module AdjustmentsUpdaterDecorator + private + + def persist_totals(totals) + attributes = totals + + attributes[:adjustment_total] = totals[:non_taxable_adjustment_total] + totals[:taxable_adjustment_total] + attributes[:adjustment_total] += totals[:additional_tax_total] unless @adjustable.suborder? + + attributes[:updated_at] = Time.current + @adjustable.update_columns(totals) + end + end + end +end + +Spree::Adjustable::AdjustmentsUpdater.prepend(SpreeMultiVendor::Adjustable::AdjustmentsUpdaterDecorator) diff --git a/app/models/spree_multi_vendor/admin/main_menu/organization_builder.rb b/app/models/spree_multi_vendor/admin/main_menu/organization_builder.rb deleted file mode 100644 index db278d61..00000000 --- a/app/models/spree_multi_vendor/admin/main_menu/organization_builder.rb +++ /dev/null @@ -1,22 +0,0 @@ -module SpreeMultiVendor - module Admin - module MainMenu - class OrganizationBuilder - include ::Spree::Core::Engine.routes.url_helpers - - def build - item = ::Spree::Admin::MainMenu::ItemBuilder.new('vendors', admin_vendors_path). - with_availability_check(->(ability, _store) { ability.can?(:manage, ::Spree::Vendor) && ability.can?(:index, ::Spree::Vendor) }). - with_label_translation_key('vendors'). - with_match_path('/vendors'). - build - - ::Spree::Admin::MainMenu::SectionBuilder.new('organizations', 'building.svg'). - with_label_translation_key('organizations'). - with_item(item). - build - end - end - end - end -end diff --git a/app/models/spree_multi_vendor/admin/tabs/vendor_tabs_builder.rb b/app/models/spree_multi_vendor/admin/tabs/vendor_tabs_builder.rb deleted file mode 100644 index d8215391..00000000 --- a/app/models/spree_multi_vendor/admin/tabs/vendor_tabs_builder.rb +++ /dev/null @@ -1,42 +0,0 @@ -module SpreeMultiVendor - module Admin - module Tabs - class VendorTabsBuilder - include ::Spree::Core::Engine.routes.url_helpers - - def build - root = ::Spree::Admin::Tabs::Root.new - add_details_tab(root) - add_translations_tab(root) - root - end - - private - - def add_details_tab(root) - tab = ::Spree::Admin::Tabs::TabBuilder.new('details', ->(resource) { edit_admin_vendor_path(resource) }). - with_icon_key('edit.svg'). - with_active_check. - with_availability_check(->(ability, resource) { ability.can?(:edit, resource) }). - build - - root.add(tab) - end - - def add_translations_tab(root) - tab = ::Spree::Admin::Tabs::TabBuilder.new('translations', ->(resource) { translations_admin_vendor_path(resource) }). - with_icon_key('translate.svg'). - with_active_check. - with_availability_check( - lambda do |ability, resource| - ability.can?(:edit, resource) && !resource.deleted? - end - ). - build - - root.add(tab) - end - end - end - end -end diff --git a/app/models/spree_multi_vendor/admin_user_decorator.rb b/app/models/spree_multi_vendor/admin_user_decorator.rb new file mode 100644 index 00000000..4c61b53e --- /dev/null +++ b/app/models/spree_multi_vendor/admin_user_decorator.rb @@ -0,0 +1,11 @@ +module SpreeMultiVendor + module AdminUserDecorator + def self.prepended(base) + base.has_many :vendors, through: :role_users, source: :resource, source_type: 'Spree::Vendor' + end + end +end + +if Spree.admin_user_class.present? + Spree.admin_user_class.prepend(SpreeMultiVendor::AdminUserDecorator) unless Spree.admin_user_class.include?(SpreeMultiVendor::AdminUserDecorator) +end diff --git a/app/models/spree_multi_vendor/inventory_unit_decorator.rb b/app/models/spree_multi_vendor/inventory_unit_decorator.rb new file mode 100644 index 00000000..1f4f4000 --- /dev/null +++ b/app/models/spree_multi_vendor/inventory_unit_decorator.rb @@ -0,0 +1,35 @@ +module SpreeMultiVendor + module InventoryUnitDecorator + def self.prepended(base) + base.scope :available_to_split, lambda { + joins(:shipment). + where( + state: 'on_hand', + Spree::Shipment.table_name => { external_id: nil } + ). + where.not( + Spree::Shipment.table_name => { state: [:canceled, :shipped] } + ) + } + end + + # def marketplace_commission_additional_tax + # subtotal = marketplace_commission_subtotal + # item_taxes = line_item.adjustments.additional.tax + # tax_rates = item_taxes.map(&:source) + + # tax_rates.sum(Spree::Money.new(0, currency: line_item.currency)) { |tax_rate| subtotal * tax_rate.amount } + # end + + def marketplace_commission_total + (line_item.display_platform_fee_per_unit * quantity) - line_item.display_platform_fee_discount + end + + def extract_inventory!(new_quantity:) + remaining_quantity = quantity - new_quantity + split_inventory!(remaining_quantity) if remaining_quantity.positive? + end + end +end + +Spree::InventoryUnit.prepend(SpreeMultiVendor::InventoryUnitDecorator) unless Spree::InventoryUnit.include?(SpreeMultiVendor::InventoryUnitDecorator) diff --git a/app/models/spree_multi_vendor/invitation_decorator.rb b/app/models/spree_multi_vendor/invitation_decorator.rb new file mode 100644 index 00000000..7b63116a --- /dev/null +++ b/app/models/spree_multi_vendor/invitation_decorator.rb @@ -0,0 +1,10 @@ +module SpreeMultiVendor + module InvitationDecorator + def after_accept + super + resource.start_onboarding! if resource.is_a?(Spree::Vendor) && (resource.invited? || resource.pending?) + end + end +end + +Spree::Invitation.prepend(SpreeMultiVendor::InvitationDecorator) diff --git a/app/models/spree_multi_vendor/line_item_decorator.rb b/app/models/spree_multi_vendor/line_item_decorator.rb new file mode 100644 index 00000000..14f8d7f7 --- /dev/null +++ b/app/models/spree_multi_vendor/line_item_decorator.rb @@ -0,0 +1,83 @@ +module SpreeMultiVendor + module LineItemDecorator + def self.prepended(base) + base.include Spree::VendorConcern + + base.before_validation :set_vendor_from_product + + base.has_many :marketplace_commissions, as: :feeable, class_name: 'Spree::MarketplaceCommission', dependent: :destroy + + base.scope :platform_feeable, lambda { + where("#{Spree::LineItem.table_name}.vendor_id IS NOT NULL AND #{Spree::LineItem.table_name}.pre_tax_amount > 0") + } + + base.scope :with_product_external_id, ->(external_id) { joins(:product).merge(Spree::Product.by_external_id(external_id)) } + base.scope :by_variant_external_id, ->(external_id) { joins(:variant).merge(Spree::Variant.with_deleted.by_external_id(external_id)) } + + base.money_methods :platform_fee_total, :platform_fee_per_unit, :platform_fee_discount + base.money_methods :additional_tax_before_commission_total + + base.store_accessor :private_metadata, :platform_fee_discount + + base.delegate :suborder?, to: :order + end + + def price_after_fees + price - platform_fee_per_unit + end + + def subtotal_after_fees + price_after_fees * quantity + end + + def total_after_fees + total - platform_fee_total + end + + def unit_discount_amount + display_promo_total.amount_in_cents / quantity + end + + def unit_tax_amount + display_tax_total.amount_in_cents / quantity + end + + def discounted_amount + amount + taxable_adjustment_total + end + + def discounted_amount_after_fees + subtotal_after_fees + taxable_adjustment_total + end + + def additional_tax_before_commission_total + adjustments.tax.additional.sum(0.to_d) { |tax| (tax.amount_before_commission.presence || tax.amount).to_d } + end + + def add_to_reimbursement(reimbursement, new_shipment, reimbursed_quantity) + inventory_unit_scope = fully_shipped? ? inventory_units.shipped : inventory_units.available_to_split + inventory_unit_scope = inventory_unit_scope.where(quantity: reimbursed_quantity..).order(quantity: :asc) + inventory_unit = inventory_unit_scope.first + + return false unless inventory_unit.present? + + inventory_unit.extract_inventory!(new_quantity: reimbursed_quantity) + inventory_unit.update!(shipment: new_shipment) + + return_item = inventory_unit.current_or_new_return_item + return_item.accept! + + reimbursement.return_items << return_item + + true + end + + protected + + def set_vendor_from_product + self.vendor = product&.vendor if variant.present? + end + end +end + +Spree::LineItem.prepend(SpreeMultiVendor::LineItemDecorator) diff --git a/app/models/spree_multi_vendor/option_value_decorator.rb b/app/models/spree_multi_vendor/option_value_decorator.rb new file mode 100644 index 00000000..c86dacfa --- /dev/null +++ b/app/models/spree_multi_vendor/option_value_decorator.rb @@ -0,0 +1,41 @@ +module SpreeMultiVendor + module OptionValueDecorator + def self.prepended(base) + # + # Scopes + # + base.scope :for_option_types, ->(option_types) { where(option_type: option_types) } + base.scope :distinct_by_id, lambda { + select( + [ + "#{base.table_name}.*", + "MIN(#{base.table_name}.position) as min_position" + ] + ).group(:id).reorder(min_position: :asc) + } + + base.scope :for_option_name_combinations, lambda { |combinations| + joins(:option_type).where( + Arel::Nodes::In.new( + Arel::Nodes::Grouping.new( + [ + Spree::OptionType.arel_table[:name], + base.arel_table[:name] + ] + ), + combinations.map do |options| + Arel::Nodes::Grouping.new( + [ + Arel::Nodes.build_quoted(options[0].parameterize.strip), + Arel::Nodes.build_quoted(options[1].parameterize.strip) + ] + ) + end + ) + ) + } + end + end +end + +Spree::OptionValue.prepend(SpreeMultiVendor::OptionValueDecorator) diff --git a/app/models/spree_multi_vendor/order_decorator.rb b/app/models/spree_multi_vendor/order_decorator.rb new file mode 100644 index 00000000..6bef0e0a --- /dev/null +++ b/app/models/spree_multi_vendor/order_decorator.rb @@ -0,0 +1,296 @@ +module SpreeMultiVendor + module OrderDecorator + def self.prepended(base) + base.include Spree::VendorConcern + + base.whitelisted_ransackable_attributes += %w[vendor_id] + base.whitelisted_ransackable_scopes += %w[splitted with_vendor has_vendor without_vendor] + base.whitelisted_ransackable_associations += %w[vendor_orders] + + # original order + base.has_many :vendors, lambda { + with_deleted.distinct.reorder("#{Spree::Vendor.table_name}.created_at DESC") + }, through: :products, class_name: 'Spree::Vendor' + base.has_many :vendor_orders, -> { has_vendor }, class_name: 'Spree::Order', foreign_key: 'parent_id' + base.has_many :vendor_order_vendors, through: :vendor_orders, source: :vendor, class_name: 'Spree::Vendor' + base.has_many :vendor_order_line_items, through: :vendor_orders, source: :line_items + base.has_many :vendor_order_variants, through: :vendor_order_line_items, source: :variant + base.has_many :vendor_order_shipments, through: :vendor_orders, source: :shipments + base.has_many :vendor_order_payments, through: :vendor_orders, source: :payments + base.has_many :vendor_order_refunds, through: :vendor_orders, source: :refunds + base.has_many :vendor_order_adjustments, through: :vendor_orders, source: :adjustments + base.has_many :vendor_order_line_item_adjustments, through: :vendor_orders, source: :line_item_adjustments + base.has_many :vendor_order_shipment_adjustments, through: :vendor_orders, source: :shipment_adjustments + base.has_many :vendor_order_promotions, through: :vendor_orders, source: :promotions + base.has_many :vendor_order_returns, through: :vendor_orders, source: :returns + + base.has_many :vendor_order_customer_returns, through: :vendor_orders, source: :customer_returns + base.has_many :vendor_order_marketplace_commissions, through: :vendor_orders, source: :marketplace_commissions + + # vendor order + base.validates :vendor_id, uniqueness: { scope: :parent_id }, if: :vendor_id? + base.validates :parent_id, presence: true, if: :vendor_id? + base.belongs_to :parent, class_name: 'Spree::Order', optional: true, touch: true + base.has_many :marketplace_commissions, + class_name: 'Spree::MarketplaceCommission', + foreign_key: :order_id, + dependent: :destroy, + inverse_of: :order + + base.scope :splitted, -> { where(state: 'splitted') } + base.scope :not_splitted, -> { where.not(state: 'splitted') } + + base.state_machine.after_transition to: :complete, do: :split_order + base.state_machine.after_transition to: :canceled, do: :cancel_parent_order + + base.state_machine.event :split do + transition to: :splitted, from: :complete + end + + base.money_methods :platform_fee_total, :platform_fee_reverse_total, :vendor_orders_platform_fee_total, :total_after_fees + base.alias_method :platform_fee_total_money, :display_platform_fee_total + base.alias_method :platform_fee_reverse_total_money, :display_platform_fee_reverse_total + + base.after_commit :assign_address_to_suborders, on: :update + + def base.webhook_included_relationships + @webhook_included_relationships ||= Spree::Api::V2::Platform::OrderSerializer.relationships_to_serialize.keys + + %i[ + line_items.tax_category + line_items.variant + line_items.variant.images + line_items.adjustments + line_items.inventory_units + line_items.product + line_items.product.images + + shipments.stock_location + shipments.adjustments + shipments.inventory_units + shipments.shipping_rates + + order_promotions.promotion + ] + end + end + + def item_additional_tax_before_commission_total + line_items.sum(&:additional_tax_before_commission_total) + end + + def additional_tax_before_commission_total + item_additional_tax_before_commission_total + shipments.sum(&:additional_tax_before_commission_total) + end + + def parent_order? + parent.nil? + end + + def suborder? + !parent_order? + end + + # this can be overriden to add custom logic when syncing orders with vendors external stores + def can_cancel_in_admin? + true + end + + # FIXME: all this logic needs to be moved to ability class + def can_vendor_cancel? + completed? && !canceled? && vendor.present? + end + + # we need to overwrite this method as splitted orders usually don't have line items + def checkout_allowed? + super || splitted? + end + + def covered_by_store_credit? + parent_order = parent || self + !parent_order.total_minus_store_credits.positive? && parent_order.total_applied_store_credit.positive? + end + + def total_after_fees + total_minus_store_credits + platform_fee_total + end + + def gift_card_total + parent_order = parent || self + return 0.to_d unless parent_order.gift_card.present? + + store_credit_ids = payments.store_credits.valid.pluck(:source_id) + store_credits = Spree::StoreCredit.where(id: store_credit_ids, originator: parent_order.gift_card) + + store_credits.sum(:amount) + end + + def all_shipments + @all_shipments ||= if splitted? + Spree::Shipment.where(order_id: [id] + vendor_order_ids) + else + shipments + end + end + + def all_line_items + @all_line_items = if splitted? + Spree::LineItem.where(order_id: [id] + vendor_order_ids) + else + line_items + end + end + + def all_line_item_adjustments + @all_line_item_adjustments = if splitted? + Spree::Adjustment.where(id: line_item_adjustment_ids + vendor_order_line_item_adjustment_ids) + else + line_item_adjustments + end + end + + def all_shipment_adjustments + @all_shipment_adjustments = if splitted? + Spree::Adjustment.where(id: shipment_adjustment_ids + vendor_order_shipment_adjustment_ids) + else + shipment_adjustments + end + end + + def all_variants + @all_variants = if splitted? + Spree::Variant.where(id: variant_ids + vendor_order_variant_ids) + else + variants + end + end + + def all_customer_returns + @all_customer_returns = if splitted? + vendor_order_customer_returns + else + customer_returns + end + end + + def all_refunds + @all_refunds = if splitted? + vendor_order_refunds + else + refunds + end + end + + def all_vendors + @all_vendors = if splitted? + vendor_order_vendors + else + vendors + end + end + + def any_whole_order_discount? + all_adjustments. + joins(:promotion_action). + where( + Spree::PromotionAction.table_name => { + type: 'Spree::Promotion::Actions::CreateAdjustment' + } + ).any? + end + + def splitted? + completed? && (state == 'splitted' || vendor_orders.any?) + end + + # this is the sum of all vendor orders platform fees + def vendor_orders_platform_fee_total + @vendor_orders_platform_fee_total ||= vendor_orders.sum(:platform_fee_total) + end + + def shipment_shipped + if suborder? + queue_webhooks_requests!('suborder.fulfilled') + parent.queue_webhooks_requests!('order.fulfilled') if parent.all_shipments.all?(&:shipped?) + else + queue_webhooks_requests!('order.fulfilled') + end + end + + def canceled_by(user, canceled_at = nil) + canceled_at ||= Time.current + + if parent_order? && splitted? + transaction do + vendor_orders.find_each do |vo| + vo.canceled_by(user, canceled_at) if vo.can_cancel? + end + end + else + super(user, canceled_at) + end + end + + def split! + update_columns(state: :splitted, updated_at: Time.current) + end + + def assign_address_to_suborders + return unless vendor_orders.any? + + if saved_change_to_ship_address_id? + vendor_order_shipments.update_all(address_id: ship_address.id, updated_at: Time.current) + vendor_orders.update_all(ship_address_id: ship_address.id, updated_at: Time.current) + elsif saved_change_to_bill_address_id? + vendor_orders.update_all(bill_address_id: bill_address.id, updated_at: Time.current) + end + end + + def deliver_vendor_order_notification_email? + !store.test_mode? && suborder? && !confirmation_delivered + end + + def deliver_vendor_order_notification_email + return unless deliver_vendor_order_notification_email? + + vendor.users.each do |member| + Spree::OrderMailer.with(order: self, recipient: member).vendor_order_confirm_email.deliver_later(wait: 15.seconds) + end + + update_column(:confirmation_delivered, true) + end + + private + + def recalculate_totals + update_with_updater! if parent_order? + end + + def split_order + SpreeMultiVendor::Orders::SplitByVendorJob.set(wait: 30.seconds).perform_later(id) if vendors.any? + end + + # TODO: we should move this to a background worker + def cancel_parent_order + return if parent_order? + + # 1 sub-order, we want to cancel the parent order + # or all sub-orders are cancelled, we want to cancel the parent order + if (parent.vendor_order_ids == [id] || parent.vendor_orders.all?(&:canceled?)) && parent.line_items.empty? + parent.update_columns( + state: :canceled, + updated_at: Time.current, + canceled_at: canceled_at, + shipment_state: :canceled, + payment_state: :void + ) + + parent.queue_webhooks_requests!('order.cancelled') + else + # not yet all sub-orders are cancelled, we want to mark the parent order as partially cancelled + parent.update_columns(state: :partially_canceled, updated_at: Time.current) + end + end + end +end + +Spree::Order.prepend(SpreeMultiVendor::OrderDecorator) if Spree::Order.included_modules.exclude?(SpreeMultiVendor::OrderDecorator) diff --git a/app/models/spree_multi_vendor/order_updater_decorator.rb b/app/models/spree_multi_vendor/order_updater_decorator.rb new file mode 100644 index 00000000..1d9b310a --- /dev/null +++ b/app/models/spree_multi_vendor/order_updater_decorator.rb @@ -0,0 +1,59 @@ +module SpreeMultiVendor + module OrderUpdaterDecorator + def update_adjustment_total + recalculate_adjustments + order.adjustment_total = line_items.sum(:adjustment_total) + + shipments.sum(:adjustment_total) + + adjustments.eligible.sum(:amount) + + order.included_tax_total = line_items.sum(:included_tax_total) + shipments.sum(:included_tax_total) + order.additional_tax_total = line_items.sum(:additional_tax_total) + shipments.sum(:additional_tax_total) + + order.platform_fee_total = -line_items.sum(:platform_fee_total) + + order.promo_total = line_items.sum(:promo_total) + + shipments.sum(:promo_total) + + adjustments.promotion.eligible.sum(:amount) + + update_order_total + end + + def persist_totals + super + order.update_column(:platform_fee_total, order.platform_fee_total) + end + + # this override is needed for multi-vendor orders + def update_shipment_state + if order.backordered? + order.shipment_state = 'backorder' + else + # get all the shipment states for this order + shipment_states = order.splitted? ? order.all_shipments.reload.pluck(:state).uniq.compact : shipments.reload.states.uniq + + order.shipment_state = if shipment_states.size > 1 + if shipment_states.include?('shipped') + 'partial' + elsif shipment_states.include?('pending') + 'pending' + else + 'ready' + end + else + # will return nil if no shipments are found + shipment_states.first + # TODO: inventory unit states? + # if order.shipment_state && order.inventory_units.where(shipment_id: nil).exists? + # shipments exist but there are unassigned inventory units + # order.shipment_state = 'partial' + # end + end + end + + order.state_changed('shipment') unless order.splitted? + order.shipment_state + end + end +end + +Spree::OrderUpdater.prepend(SpreeMultiVendor::OrderUpdaterDecorator) diff --git a/app/models/spree_multi_vendor/payment_decorator.rb b/app/models/spree_multi_vendor/payment_decorator.rb new file mode 100644 index 00000000..794a76d3 --- /dev/null +++ b/app/models/spree_multi_vendor/payment_decorator.rb @@ -0,0 +1,21 @@ +module SpreeMultiVendor + module PaymentDecorator + def self.prepended(base) + base.include Spree::VendorConcern + base.before_validation :set_vendor_from_order + end + + def set_vendor_from_order + self.vendor = order.vendor + end + + # We don't want to reimburse from gift card payments on vendor orders + def can_credit? + return false if vendor.present? && store_credit? + + super + end + end +end + +Spree::Payment.prepend(SpreeMultiVendor::PaymentDecorator) diff --git a/app/models/spree_multi_vendor/payment_method/store_credit_decorator.rb b/app/models/spree_multi_vendor/payment_method/store_credit_decorator.rb new file mode 100644 index 00000000..5dd23c18 --- /dev/null +++ b/app/models/spree_multi_vendor/payment_method/store_credit_decorator.rb @@ -0,0 +1,23 @@ +module SpreeMultiVendor + module PaymentMethod + module StoreCreditDecorator + def cancel(auth_code, payment = nil) + store_credit_event = ::Spree::StoreCreditEvent.find_by(authorization_code: auth_code, action: ::Spree::StoreCredit::CAPTURE_ACTION) + store_credit = store_credit_event.try(:store_credit) + + if !store_credit_event || !store_credit + handle_action(nil, :cancel, false) + else + # for suborders we don't want to refund the full store credit amount, but only the amount + # proportional to the payment for that suborder + action = lambda do |sc| + sc.credit(payment&.amount || store_credit_event.amount, auth_code, store_credit.currency) + end + handle_action(action, :cancel, auth_code) + end + end + end + end +end + +Spree::PaymentMethod::StoreCredit.prepend(SpreeMultiVendor::PaymentMethod::StoreCreditDecorator) diff --git a/app/models/spree_multi_vendor/permission_sets/super_user.rb b/app/models/spree_multi_vendor/permission_sets/super_user.rb new file mode 100644 index 00000000..e83b1086 --- /dev/null +++ b/app/models/spree_multi_vendor/permission_sets/super_user.rb @@ -0,0 +1,89 @@ +module SpreeMultiVendor + module PermissionSets + class SuperUser < Spree::PermissionSets::Base + def activate! + # reset stock permissions + cannot :manage, Spree::StockItem + cannot :manage, Spree::StockLocation + cannot :manage, Spree::StockMovement + can :read, Spree::StockItem + can :read, Spree::StockLocation + can :read, Spree::StockMovement + # disable + can :manage, Spree::StockItem, variant: { product: { vendor_id: nil } } + can :manage, Spree::StockLocation, vendor_id: nil + can :manage, Spree::StockMovement, stock_item: { variant: { product: { vendor_id: nil } } } + can :create, Spree::StockMovement + can :create, Spree::StockItem + can :create, Spree::StockLocation + + # orders + cannot :update_customer, Spree::Order + can :update_customer, Spree::Order do |o| + !o.splitted? && !o.shipped? && o.vendors.empty? && !o.canceled? + end + cannot :update_addresses, Spree::Order + can :update_addresses, Spree::Order do |o| + !o.shipped? && !o.canceled? + end + cannot %i[fire], Spree::Order, state: 'splitted' + can :resend, Spree::Order, state: 'splitted' + cannot :refund, Spree::Order, &:splitted? + cannot :cancel, Spree::Order + can :cancel, Spree::Order, &:allow_cancel? + cannot :create, Spree::Adjustment, adjustable: { state: 'splitted' } + cannot :manage, Spree::Adjustment, adjustable: { state: 'splitted' } + cannot :manage, Spree::LineItem + can :manage, Spree::LineItem, { vendor_id: nil } + + cannot :update, Spree::LineItem, order: { state: 'splitted' } + cannot :create, Spree::LineItem, order: { state: ['splitted', 'canceled'] } + + cannot :split, Spree::Shipment + can :split, Spree::Shipment, vendor_id: nil + cannot :delete, Spree::Shipment + can :delete, Spree::Shipment, vendor_id: nil + cannot :split, Spree::Shipment, state: ['shipped', 'canceled'] + can :edit, Spree::Order + can :read, Spree::Order + + # product catalog management + + cannot :create, Spree::Refund do |refund| + refund.order&.splitted? + end + cannot :edit, Spree::Refund do |refund| + payment_method = refund.payment.payment_method + payment_method.respond_to?(:stripe?) && payment_method.stripe? + end + + can :manage, Spree::ShippingMethod, vendor_id: nil + can :manage, Spree::ShippingCategory + + # we need to protect default stock location + cannot :delete, Spree::StockLocation, default: true + + # returns + can :manage, Spree::ReturnAuthorization + cannot :manage, Spree::ReturnAuthorization, order: { completed_at: nil } + cannot :manage, Spree::ReturnAuthorization, order: { state: ['splitted', 'canceled'] } + + # we need to prohibit opton types edit if product is synced from shopify/woo/etc + cannot :manage_option_types, Spree::Product + can :manage_option_types, Spree::Product, { vendor_id: nil } + cannot :destroy, Spree::Product do |product| + product.external? + end + + # allow to clone 1st party products + cannot :clone, Spree::Product + can :clone, Spree::Product do |product| + !product.external? && product.vendor.nil? + end + + # protect default vendor role + cannot [:edit, :update,:delete], Spree::Role, name: Spree::Vendor::DEFAULT_VENDOR_ROLE + end + end + end +end diff --git a/app/models/spree_multi_vendor/permission_sets/vendor_user.rb b/app/models/spree_multi_vendor/permission_sets/vendor_user.rb new file mode 100644 index 00000000..7b02d0be --- /dev/null +++ b/app/models/spree_multi_vendor/permission_sets/vendor_user.rb @@ -0,0 +1,203 @@ +module SpreeMultiVendor + module PermissionSets + class VendorUser < Spree::PermissionSets::Base + def activate! + @vendor_ids = user.vendors.ids + + return if @vendor_ids.empty? + + can :manage, Spree::Vendor, id: @vendor_ids + cannot %i[delete approve reject suspend delete], Spree::Vendor + + can :read, Spree::Role, name: Spree::Vendor::DEFAULT_VENDOR_ROLE + + apply_asset_permissions + apply_dashboard_permissions + apply_classifications_permissions + apply_order_permissions + apply_import_permissions + apply_option_type_permissions + apply_price_permissions + apply_product_option_type_permissions + apply_product_permissions + apply_shipment_permissions + apply_shipping_methods_permissions + apply_stock_permissions + apply_stock_item_permissions + apply_stock_location_permissions + apply_stock_movement_permissions + apply_variant_permissions + apply_returns_permissions + apply_reports_permissions + apply_digital_permissions + apply_taxons_permissions + apply_export_permissions + apply_import_permissions + + can :manage, :vendor_settings + + can :create, Spree::Invitation + can :manage, Spree::Invitation, resource_type: 'Spree::Vendor', resource_id: @vendor_ids + + can [:read, :manage], Spree.admin_user_class, role_users: { resource_type: 'Spree::Vendor', resource_id: @vendor_ids } + can :manage, Spree::RoleUser, resource_type: 'Spree::Vendor', resource_id: @vendor_ids + + can :manage, :profile + + cannot :mark_as_default, Spree::StockLocation + + cannot :manage, Spree::Policy + can :manage, Spree::Policy, owner_type: 'Spree::Vendor', owner_id: @vendor_ids + cannot :create, Spree::Policy + end + + private + + def apply_asset_permissions + can %i[new create], Spree::Asset + + can %i[manage modify], Spree::Asset, viewable_type: 'Spree::Variant', viewable_id: nil + can %i[manage modify], Spree::Asset, viewable_type: 'Spree::Variant', viewable_id: Spree::Variant.with_vendor(@vendor_ids).ids + can %i[manage modify], Spree::Asset, viewable_type: 'Spree::Vendor', viewable_id: @vendor_ids + end + + def apply_dashboard_permissions + can :manage, :dashboard + end + + def apply_classifications_permissions + can :manage, Spree::Classification, product: { vendor_id: @vendor_ids } + end + + def apply_order_permissions + can :manage, Spree::Order, { vendor_id: @vendor_ids } + cannot :cancel, Spree::Order + cannot :resend, Spree::Order + cannot :create, Spree::Order + cannot :update_customer, Spree::Order + can :cancel, Spree::Order, { vendor_id: @vendor_ids } + end + + def apply_import_permissions + end + + def apply_option_type_permissions + can :display, Spree::OptionType + can :display, Spree::OptionValue + end + + def apply_price_permissions + can :create, Spree::Price + can :manage, Spree::Price, variant: { product: { vendor_id: @vendor_ids } } + end + + def apply_product_option_type_permissions + can :manage, Spree::ProductOptionType, product: { vendor_id: @vendor_ids } + can :create, Spree::ProductOptionType + can :manage, Spree::OptionValueVariant, variant: { product: { vendor_id: @vendor_ids } } + can :create, Spree::OptionValueVariant + end + + def apply_product_permissions + cannot :read, Spree::Product + can :manage, Spree::Product, vendor_id: @vendor_ids + cannot :manage_option_types, Spree::Product + can :manage_option_types, Spree::Product, vendor_id: @vendor_ids + can :create, Spree::Product + cannot :activate, Spree::Product + cannot :manage_tags, Spree::Product + cannot :manage_labels, Spree::Product + cannot :manage_commission, Spree::Product + cannot %i[clone destroy], Spree::Product do |product| + product.external_id.present? + end + end + + def apply_shipment_permissions + can %i[manage ready ship cancel resume pend], Spree::Shipment, order: { vendor_id: @vendor_ids } + cannot :split, Spree::Shipment + end + + def apply_shipping_methods_permissions + can :manage, Spree::ShippingMethod, vendor_id: @vendor_ids + can :create, Spree::ShippingMethod + cannot :manage, Spree::ShippingCategory + end + + def apply_stock_permissions + can :admin, Spree::Stock + end + + def apply_stock_item_permissions + can %i[create bulk_set_backorderable bulk_set_track_inventory], Spree::StockItem + can %i[admin modify read toggle_backorderable manage], Spree::StockItem, variant: { product: { vendor_id: @vendor_ids } } + end + + def apply_stock_location_permissions + can :manage, Spree::StockLocation, vendor_id: @vendor_ids + cannot :create, Spree::StockLocation + cannot :destroy, Spree::StockLocation + end + + def apply_stock_movement_permissions + can :create, Spree::StockMovement + can :manage, Spree::StockMovement, stock_item: { variant: { product: { vendor_id: @vendor_ids } } } + end + + def apply_variant_permissions + cannot :read, Spree::Variant + can :manage, Spree::Variant, product: { vendor_id: @vendor_ids } + cannot :destroy, Spree::Variant + cannot :create, Spree::Variant + can [:edit, :create, :destroy], Spree::Variant, product: { external_product_id: nil, vendor_id: @vendor_ids } + end + + def apply_returns_permissions + can :create, Spree::CustomerReturn + can :manage, Spree::CustomerReturn, stock_location: { vendor_id: @vendor_ids } + can :create, Spree::Refund + can :manage, Spree::Refund, payment: { order: { vendor_id: @vendor_ids } } + can :create, Spree::Reimbursement + can :manage, Spree::Reimbursement, order: { vendor_id: @vendor_ids } + + can :manage, Spree::ReturnAuthorization, order: { vendor_id: @vendor_ids } + + can :read, Spree::ReimbursementType + can :read, Spree::RefundReason + can :read, Spree::ReturnAuthorizationReason + can :manage, Spree::ReturnItem, inventory_unit: { order: { vendor_id: @vendor_ids } } + end + + def apply_reports_permissions + can :manage, Spree::Report, vendor_id: @vendor_ids + end + + def apply_digital_permissions + can :create, Spree::Digital + can :manage, Spree::Digital, variant: { product: { vendor_id: @vendor_ids } } + can :create, Spree::DigitalLink + can :manage, Spree::DigitalLink, line_item: { order: { vendor_id: @vendor_ids } } + end + + def apply_taxons_permissions + can %i[index show], Spree::Taxonomy + can %i[index show admin select_options], Spree::Taxon + cannot :manage, Spree::Classification + end + + def apply_export_permissions + can :admin, Spree::Export + can :create, Spree::Export + can :read, Spree::Export, vendor_id: @vendor_ids + end + + def apply_import_permissions + can :admin, Spree::Import + can :manage, Spree::Import, owner_type: 'Spree::Vendor', owner_id: @vendor_ids + can :create, Spree::Import + can :manage, Spree::ImportRow, import: { owner_type: 'Spree::Vendor', owner_id: @vendor_ids } + can :manage, Spree::ImportMapping, import: { owner_type: 'Spree::Vendor', owner_id: @vendor_ids } + end + end + end +end diff --git a/app/models/spree_multi_vendor/policy_decorator.rb b/app/models/spree_multi_vendor/policy_decorator.rb new file mode 100644 index 00000000..f22e4b80 --- /dev/null +++ b/app/models/spree_multi_vendor/policy_decorator.rb @@ -0,0 +1,11 @@ +module SpreeMultiVendor + module PolicyDecorator + def self.prepended(base) + base.scope :with_vendor, ->(vendor_id) { where(owner_type: 'Spree::Vendor', owner_id: vendor_id) } + base.scope :has_vendor, -> { where.not(owner_type: 'Spree::Vendor') } + base.scope :without_vendor, -> { where.not(owner_type: 'Spree::Vendor') } + end + end + + ::Spree::Policy.prepend(SpreeMultiVendor::PolicyDecorator) unless Spree::Policy.include?(SpreeMultiVendor::PolicyDecorator) +end diff --git a/app/models/spree_multi_vendor/product_decorator.rb b/app/models/spree_multi_vendor/product_decorator.rb new file mode 100644 index 00000000..0d666d7a --- /dev/null +++ b/app/models/spree_multi_vendor/product_decorator.rb @@ -0,0 +1,71 @@ +module SpreeMultiVendor + module ProductDecorator + def self.prepended(base) + base.extend Spree::DisplayMoney + base.money_methods :commission_amount + + base.whitelisted_ransackable_attributes += %w[vendor_id] + base.whitelisted_ransackable_associations += %w[products_external_categories products_external_product_type] + + base.state_machine.event :pause do + transition to: :paused + end + + base.has_many :vendor_shipping_methods, ->(product) { with_vendor(product.vendor_id) }, through: :shipping_category, source: :shipping_methods + base.has_many :marketplace_shipping_methods, -> { without_vendor }, through: :shipping_category, source: :shipping_methods + + base.after_commit :update_vendor_products_count, on: %i[create destroy] + + base.delegate :name, to: :vendor, prefix: true, allow_nil: true + end + + def shipping_methods + vendor_id.present? ? vendor_shipping_methods : marketplace_shipping_methods + end + + def restore_with_variants_only + return unless paranoia_destroyed? + + ApplicationRecord.transaction do + restore + + uniq_deleted_variants = variants_including_master.only_deleted.uniq(&:external_id) + live_variant_external_ids = variants_including_master.pluck(:external_id) + + variants_to_restore = uniq_deleted_variants.reject { |variant| variant.external_id.in?(live_variant_external_ids) } + variants_to_restore.each(&:restore) + end + end + + def commission_rate + @commission_rate ||= platform_fee.presence || vendor&.platform_fee + end + + def commission_amount + return if commission_rate.blank? + return if price.blank? + + @commission_amount ||= price * (commission_rate / 100) + end + + def update_vendor_products_count + return unless vendor.present? + + vendor.update_columns(products_count: vendor.products.count, updated_at: Time.current) + end + + def external? + defined?(external_id) && external_id.present? + end + + private + + def ensure_not_in_complete_orders + return if external_id.present? + + super + end + end +end + +Spree::Product.prepend(SpreeMultiVendor::ProductDecorator) diff --git a/app/models/spree_multi_vendor/promotion_decorator.rb b/app/models/spree_multi_vendor/promotion_decorator.rb new file mode 100644 index 00000000..fdffa94b --- /dev/null +++ b/app/models/spree_multi_vendor/promotion_decorator.rb @@ -0,0 +1,9 @@ +module SpreeMultiVendor + module PromotionDecorator + def self.prepended(base) + base.const_set(:UNACTIVATABLE_ORDER_STATES, ['complete', 'awaiting_return', 'returned', 'splitted']) + end + end +end + +Spree::Promotion.prepend(SpreeMultiVendor::PromotionDecorator) diff --git a/app/models/spree_multi_vendor/reimbursement_decorator.rb b/app/models/spree_multi_vendor/reimbursement_decorator.rb new file mode 100644 index 00000000..b4b69bff --- /dev/null +++ b/app/models/spree_multi_vendor/reimbursement_decorator.rb @@ -0,0 +1,27 @@ +module SpreeMultiVendor + module ReimbursementDecorator + def perform!(performer = nil, send_email: true, custom_total: nil) + update!(total: custom_total || calculated_total, performed_by: performer) + + reimbursement_performer.perform(self) + + if unpaid_amount_within_tolerance? || custom_total.present? + reimbursed! + reimbursement_success_hooks.each { |h| h.call self } + send_reimbursement_email if send_email + else + errored! + reimbursement_failure_hooks.each { |h| h.call self } + raise Spree::Reimbursement::IncompleteReimbursementError, Spree.t('validation.unpaid_amount_not_zero', amount: unpaid_amount) + end + end + + def custom_total? + return false if total.nil? + + !total.between?(calculated_total - 0.01.to_d, calculated_total + 0.01.to_d) + end + end +end + +Spree::Reimbursement.prepend(SpreeMultiVendor::ReimbursementDecorator) diff --git a/app/models/spree_multi_vendor/return_item_decorator.rb b/app/models/spree_multi_vendor/return_item_decorator.rb new file mode 100644 index 00000000..48f55c52 --- /dev/null +++ b/app/models/spree_multi_vendor/return_item_decorator.rb @@ -0,0 +1,47 @@ +module SpreeMultiVendor + module ReturnItemDecorator + def self.prepended(base) + base.class_attribute :refund_tax_amount_calculator + base.refund_tax_amount_calculator = ::Spree::Calculators::Returns::DefaultRefundTaxAmount + + base.before_create :calculate_refund_tax_amounts + base.before_save :set_tax_amounts_to_zero, if: :exchange_requested? + + base.money_methods :marketplace_commission_total, :marketplace_total + base.money_methods :additional_tax_total, :additional_tax_before_commission_total + + base.store_accessor :private_metadata, :additional_tax + base.money_methods :amount_before_commission + end + + def marketplace_commission_total + return_quantity * line_item.platform_fee_per_unit + end + + def marketplace_total + pre_tax_amount + marketplace_commission_total + end + + def additional_tax_before_commission_total + item_taxes = line_item.adjustments.additional.tax + tax_rates = item_taxes.map(&:source) + tax_rates.sum(0.to_d) { |tax_rate| line_item.price * return_quantity * tax_rate.amount } + end + + private + + def calculate_refund_tax_amounts + calculator = refund_tax_amount_calculator.new + + self.additional_tax_total = calculator.compute(self, type: :additional) + self.included_tax_total = calculator.compute(self, type: :included) + end + + def set_tax_amounts_to_zero + self.additional_tax_total = 0.to_d + self.included_tax_total = 0.to_d + end + end +end + +Spree::ReturnItem.prepend(SpreeMultiVendor::ReturnItemDecorator) unless Spree::ReturnItem.included_modules.include?(SpreeMultiVendor::ReturnItemDecorator) diff --git a/app/models/spree_multi_vendor/shipment_decorator.rb b/app/models/spree_multi_vendor/shipment_decorator.rb new file mode 100644 index 00000000..15b18da9 --- /dev/null +++ b/app/models/spree_multi_vendor/shipment_decorator.rb @@ -0,0 +1,34 @@ +module SpreeMultiVendor + module ShipmentDecorator + def self.prepended(base) + base.before_validation :set_vendor_from_stock_location + base.state_machine.event :cancel do + transition to: :canceled, from: %i(pending ready shipped) + end + + base.delegate :suborder?, to: :order + end + + def refresh_rates(shipping_method_filter = ::Spree::ShippingMethod::DISPLAY_ON_FRONT_END) + return shipping_rates if canceled? + + super + end + + def items_marketplace_commission_total + inventory_units.sum(Spree::Money.new(0, currency: currency), &:marketplace_commission_total) + end + + def additional_tax_before_commission_total + adjustments.tax.additional.sum(0.to_d) { |tax| (tax.amount_before_commission.presence || tax.amount).to_d } + end + + protected + + def set_vendor_from_stock_location + self.vendor ||= stock_location&.vendor || order&.vendor + end + end +end + +Spree::Shipment.prepend(SpreeMultiVendor::ShipmentDecorator) diff --git a/app/models/spree_multi_vendor/shipment_handler_decorator.rb b/app/models/spree_multi_vendor/shipment_handler_decorator.rb new file mode 100644 index 00000000..b7eb7aab --- /dev/null +++ b/app/models/spree_multi_vendor/shipment_handler_decorator.rb @@ -0,0 +1,32 @@ +module SpreeMultiVendor + module ShipmentHandlerDecorator + def perform + super + order.shipment_shipped + end + + private + + def order + @shipment.order + end + + def store + @shipment.order.store + end + + def update_order_shipment_state + super + + @order = @shipment.order + @parent_order = @order.parent + + return if @parent_order.nil? + + new_state_for_parent_order = ::Spree::OrderUpdater.new(@parent_order).update_shipment_state + @parent_order.update_columns(shipment_state: new_state_for_parent_order, updated_at: Time.current) + end + end +end + +Spree::ShipmentHandler.prepend(SpreeMultiVendor::ShipmentHandlerDecorator) diff --git a/app/models/spree_multi_vendor/shipping_method_decorator.rb b/app/models/spree_multi_vendor/shipping_method_decorator.rb new file mode 100644 index 00000000..5b0c6e68 --- /dev/null +++ b/app/models/spree_multi_vendor/shipping_method_decorator.rb @@ -0,0 +1,19 @@ +module SpreeMultiVendor + module ShippingMethodDecorator + def self.prepended(base) + base.after_save :touch_vendor, if: -> { vendor_id } + end + + # connected methods are defined by vendors + # and associated with a global shipping method + def connected_method? + vendor_id.present? + end + + def touch_vendor + vendor.touch + end + end +end + +Spree::ShippingMethod.prepend(SpreeMultiVendor::ShippingMethodDecorator) diff --git a/app/models/spree_multi_vendor/spree/ability_decorator.rb b/app/models/spree_multi_vendor/spree/ability_decorator.rb deleted file mode 100644 index 820b7dd6..00000000 --- a/app/models/spree_multi_vendor/spree/ability_decorator.rb +++ /dev/null @@ -1,9 +0,0 @@ -module SpreeMultiVendor::Spree::AbilityDecorator - private - - def abilities_to_register - super << Spree::VendorAbility - end -end - -Spree::Ability.prepend SpreeMultiVendor::Spree::AbilityDecorator diff --git a/app/models/spree_multi_vendor/spree/base_decorator.rb b/app/models/spree_multi_vendor/spree/base_decorator.rb deleted file mode 100644 index fb1ec8fd..00000000 --- a/app/models/spree_multi_vendor/spree/base_decorator.rb +++ /dev/null @@ -1,13 +0,0 @@ -module SpreeMultiVendor::Spree::BaseDecorator - SpreeMultiVendor.vendorized_models.each do |model| - model.include Spree::VendorConcern - - if model.method_defined?(:whitelisted_ransackable_associations) - if model.whitelisted_ransackable_associations - model.whitelisted_ransackable_associations += %w[vendor] - else - model.whitelisted_ransackable_associations = %w[vendor] - end - end - end -end diff --git a/app/models/spree_multi_vendor/spree/line_item_decorator.rb b/app/models/spree_multi_vendor/spree/line_item_decorator.rb deleted file mode 100644 index 6cc343e3..00000000 --- a/app/models/spree_multi_vendor/spree/line_item_decorator.rb +++ /dev/null @@ -1,48 +0,0 @@ -module SpreeMultiVendor::Spree::LineItemDecorator - def self.prepended(base) - base.scope :for_vendor, ->(vendor) do - if ::Spree::LineItem.reflect_on_association(:vendor) - where(vendor_id: vendor.id) - elsif ::Spree::Product.reflect_on_association(:vendor) - joins(variant: :product).where('spree_products.vendor_id' => vendor.id) - elsif ::Spree::Variant.reflect_on_association(:vendor) - joins(:variant).where('spree_variants.vendor_id' => vendor.id) - else - none - end - end - base.scope :not_for_vendor, ->(vendor) do - if ::Spree::Product.reflect_on_association(:vendor) - where.not(vendor_id: vendor.id) - elsif ::Spree::Product.reflect_on_association(:vendor) - joins(variant: :product).where.not('spree_products.vendor_id' => vendor.id) - elsif ::Spree::Variant.reflect_on_association(:vendor) - joins(:variant).where.not('spree_variants.vendor_id' => vendor.id) - else - none - end - end - end - - def vendor - @vendor ||= if self.class.reflect_on_association(:vendor) && self[:vendor_id].present? - ::Spree::Vendor.unscoped.find(self[:vendor_id]) - elsif product.class.reflect_on_association(:vendor) && product.vendor_id.present? - product.vendor - elsif variant.class.reflect_on_association(:vendor) && variant.vendor_id.present? - variant.vendor - end - end - - def vendor_id - @vendor_id ||= if self.class.reflect_on_association(:vendor) && self[:vendor_id].present? - self[:vendor_id] - elsif product.class.reflect_on_association(:vendor) && product.vendor_id.present? - product.vendor_id - elsif variant.class.reflect_on_association(:vendor) && variant.vendor_id.present? - variant.vendor_id - end - end -end - -Spree::LineItem.prepend SpreeMultiVendor::Spree::LineItemDecorator diff --git a/app/models/spree_multi_vendor/spree/order_decorator.rb b/app/models/spree_multi_vendor/spree/order_decorator.rb deleted file mode 100644 index 73188cf2..00000000 --- a/app/models/spree_multi_vendor/spree/order_decorator.rb +++ /dev/null @@ -1,107 +0,0 @@ -module SpreeMultiVendor::Spree::OrderDecorator - def self.prepended(base) - base.has_many :commissions, class_name: 'Spree::OrderCommission' - base.state_machine.after_transition to: :complete, do: :generate_order_commissions - base.state_machine.after_transition to: :complete, do: :send_notification_mails_to_vendors - end - - def generate_order_commissions - Spree::Orders::GenerateCommissions.call(order: self) - end - - def vendor_line_items(vendor) - line_items.for_vendor(vendor) - end - - def vendor_shipments(vendor) - shipments.for_vendor(vendor) - end - - def vendor_ship_total(vendor) - vendor_shipments(vendor).sum(&:final_price) - end - - def vendor_subtotal(vendor) - vendor_pre_tax_item_amount(vendor) + - vendor_pre_tax_ship_amount(vendor) + - vendor_additional_tax_total(vendor) - end - - def vendor_promo_total(vendor) - vendor_line_items(vendor).sum(:promo_total) + vendor_shipments(vendor).sum(:promo_total) - end - - def vendor_additional_tax_total(vendor) - vendor_line_items(vendor).sum(:additional_tax_total) + vendor_shipments(vendor).sum(:additional_tax_total) - end - - def vendor_included_tax_total(vendor) - vendor_line_items(vendor).sum(:included_tax_total) + vendor_shipments(vendor).sum(:included_tax_total) - end - - def vendor_pre_tax_item_amount(vendor) - vendor_line_items(vendor).sum(:pre_tax_amount) - end - - def vendor_pre_tax_ship_amount(vendor) - vendor_shipments(vendor).sum(:pre_tax_amount) - end - - def vendor_pre_tax_total(vendor) - vendor_line_items(vendor).sum(:pre_tax_amount) + vendor_shipments(vendor).sum(:pre_tax_amount) - end - - def vendor_item_count(vendor) - vendor_line_items(vendor).sum(:quantity) - end - - def vendor_total(vendor) - vendor_line_items(vendor).sum(&:total) + vendor_ship_total(vendor) - end - - def display_order_commission - Spree::Money.new(commissions.sum(:amount), { currency: currency }) - end - - def vendor_commission(vendor) - commissions.for_vendor(vendor).sum(:amount) - end - - # money methods - METHOD_NAMES ||= %w[ - total ship_total subtotal included_tax_total additional_tax_total promo_total - pre_tax_item_amount pre_tax_ship_amount pre_tax_total commission - ].freeze - - METHOD_NAMES.each do |method_name| - define_method("display_vendor_#{method_name}") do |vendor| - Spree::Money.new(send("vendor_#{method_name}", vendor), { currency: currency }) - end - end - - def send_notification_mails_to_vendors - vendor_ids.each do |vendor_id| - Spree::VendorMailer.vendor_notification_email(id, vendor_id).deliver_later - end - end - - # we're leaving this on purpose so it can be easily modified to fit desired scenario - # eg. scenario A - vendorized products, scenario B - vendorized variants of the same product - def vendor_ids - @vendor_ids ||= line_items.map(&:vendor_id).uniq.compact - end - - def vendor_list - @vendor_list ||= line_items.map(&:vendor).uniq.compact - end - - def vendor_totals - return if vendor_list.none? - - vendor_list.map do |vendor| - Spree::VendorOrderTotals.new(vendor: vendor, order: self) - end - end -end - -Spree::Order.prepend SpreeMultiVendor::Spree::OrderDecorator unless Spree::Order.included_modules.include?(SpreeMultiVendor::Spree::OrderDecorator) diff --git a/app/models/spree_multi_vendor/spree/shipment_decorator.rb b/app/models/spree_multi_vendor/spree/shipment_decorator.rb deleted file mode 100644 index e171b9bc..00000000 --- a/app/models/spree_multi_vendor/spree/shipment_decorator.rb +++ /dev/null @@ -1,8 +0,0 @@ -module SpreeMultiVendor::Spree::ShipmentDecorator - def self.prepended(base) - base.scope :for_vendor, ->(vendor) { joins(stock_location: :vendor).where('spree_stock_locations.vendor_id' => vendor.id) } - base.scope :not_for_vendor, ->(vendor) { joins(stock_location: :vendor).where.not('spree_stock_locations.vendor_id' => vendor.id) } - end -end - -Spree::Shipment.prepend SpreeMultiVendor::Spree::ShipmentDecorator diff --git a/app/models/spree_multi_vendor/spree/stock/package_decorator.rb b/app/models/spree_multi_vendor/spree/stock/package_decorator.rb deleted file mode 100644 index 9be7a21d..00000000 --- a/app/models/spree_multi_vendor/spree/stock/package_decorator.rb +++ /dev/null @@ -1,14 +0,0 @@ -module SpreeMultiVendor::Spree::Stock::PackageDecorator - def shipping_methods - vendor = Spree::StockLocation.method_defined?(:vendor) ? stock_location.vendor : nil - - # return only shipping methods for this Vendor - if vendor && Spree::ShippingMethod.method_defined?(:vendor) - vendor.shipping_methods.to_a - else - shipping_categories.map(&:shipping_methods).reduce(:&).to_a - end - end -end - -Spree::Stock::Package.prepend SpreeMultiVendor::Spree::Stock::PackageDecorator diff --git a/app/models/spree_multi_vendor/spree/user_decorator.rb b/app/models/spree_multi_vendor/spree/user_decorator.rb deleted file mode 100644 index 9c1a0188..00000000 --- a/app/models/spree_multi_vendor/spree/user_decorator.rb +++ /dev/null @@ -1,8 +0,0 @@ -module SpreeMultiVendor::Spree::UserDecorator - def self.prepended(base) - base.has_many :vendor_users, class_name: 'Spree::VendorUser' - base.has_many :vendors, through: :vendor_users, class_name: 'Spree::Vendor' - end -end - -Spree.user_class.prepend SpreeMultiVendor::Spree::UserDecorator diff --git a/app/models/spree_multi_vendor/spree/variant_decorator.rb b/app/models/spree_multi_vendor/spree/variant_decorator.rb deleted file mode 100644 index a78a4ae1..00000000 --- a/app/models/spree_multi_vendor/spree/variant_decorator.rb +++ /dev/null @@ -1,40 +0,0 @@ -module SpreeMultiVendor::Spree::VariantDecorator - def self.prepended(base) - base.before_create :assign_vendor - - base.scope :for_vendor_user, ->(user) { where(vendor_id: user.vendor_ids) } - end - - def vendor - @vendor ||= if self.class.reflect_on_association(:vendor) && self[:vendor_id].present? - ::Spree::Vendor.unscoped.find(self[:vendor_id]) - elsif Spree::Product.reflect_on_association(:vendor) && product.vendor_id.present? - product.vendor - end - end - - def vendor_id - @vendor_id ||= if self.class.reflect_on_association(:vendor) && self[:vendor_id].present? - self[:vendor_id] - elsif Spree::Product.reflect_on_association(:vendor) && product.vendor_id.present? - product.vendor_id - end - end - - private - - def assign_vendor - return if !product.class.reflect_on_association(:vendor) || product.vendor.blank? - return if !self.class.reflect_on_association(:vendor) || self[:vendor_id].present? - - self.vendor = product.vendor - end - - def create_stock_items - Spree::StockLocation.where(propagate_all_variants: true, vendor_id: vendor_id).each do |stock_location| - stock_location.propagate_variant(self) - end - end -end - -Spree::Variant.prepend SpreeMultiVendor::Spree::VariantDecorator diff --git a/app/models/spree_multi_vendor/stock/coordinator_decorator.rb b/app/models/spree_multi_vendor/stock/coordinator_decorator.rb new file mode 100644 index 00000000..e686be41 --- /dev/null +++ b/app/models/spree_multi_vendor/stock/coordinator_decorator.rb @@ -0,0 +1,27 @@ +module SpreeMultiVendor + module Stock + module CoordinatorDecorator + def build_packages(packages = []) + stock_locations_with_requested_variants.each do |stock_location| + vendor_id = stock_location.vendor_id + selected_inventory_units = vendor_id.present? ? + find_vendor_inventory_units(inventory_units, vendor_id) : + inventory_units + + packer = build_packer(stock_location, selected_inventory_units) + packages += packer.packages + end + + packages + end + + private + + def find_vendor_inventory_units(inventory_units, vendor_id) + inventory_units.find_all { |unit| unit.line_item.vendor_id == vendor_id } + end + end + end +end + +Spree::Stock::Coordinator.prepend(SpreeMultiVendor::Stock::CoordinatorDecorator) diff --git a/app/models/spree_multi_vendor/stock/package_decorator.rb b/app/models/spree_multi_vendor/stock/package_decorator.rb new file mode 100644 index 00000000..58cb7621 --- /dev/null +++ b/app/models/spree_multi_vendor/stock/package_decorator.rb @@ -0,0 +1,15 @@ +module SpreeMultiVendor + module Stock + module PackageDecorator + def shipping_methods + if stock_location.vendor_id.present? + super.to_a.find_all { |sm| sm.vendor_id == stock_location.vendor_id } + else + super.to_a.find_all { |sm| sm.vendor_id.blank? } + end + end + end + end +end + +Spree::Stock::Package.prepend(SpreeMultiVendor::Stock::PackageDecorator) diff --git a/app/models/spree_multi_vendor/store_decorator.rb b/app/models/spree_multi_vendor/store_decorator.rb new file mode 100644 index 00000000..375cbbc4 --- /dev/null +++ b/app/models/spree_multi_vendor/store_decorator.rb @@ -0,0 +1,17 @@ +module SpreeMultiVendor + module StoreDecorator + def self.prepended(base) + base.preference :platform_fee, :decimal, default: 30 + base.preference :vendor_payouts_schedule_interval, :string, default: 'monthly' + base.preference :test_mode, :boolean, default: false + base.has_rich_text :vendor_terms_of_service + base.has_one_attached :shopify_app_image + end + + def test_mode? + preferred_test_mode + end + end +end + +Spree::Store.prepend(SpreeMultiVendor::StoreDecorator) diff --git a/app/models/spree_multi_vendor/taxon_rules/vendor.rb b/app/models/spree_multi_vendor/taxon_rules/vendor.rb new file mode 100644 index 00000000..9451afb4 --- /dev/null +++ b/app/models/spree_multi_vendor/taxon_rules/vendor.rb @@ -0,0 +1,19 @@ +module SpreeMultiVendor + module TaxonRules + class Vendor < Spree::TaxonRule + def apply(scope) + if match_policy == 'is_equal_to' + scope.where(vendor_id: value) + elsif match_policy == 'is_not_equal_to' + scope.where.not(vendor_id: value) + else + scope + end + end + + def vendor + @vendor ||= Spree::Vendor.with_deleted.find_by(id: value) + end + end + end +end diff --git a/app/models/spree_multi_vendor/user_decorator.rb b/app/models/spree_multi_vendor/user_decorator.rb new file mode 100644 index 00000000..45efdadb --- /dev/null +++ b/app/models/spree_multi_vendor/user_decorator.rb @@ -0,0 +1,16 @@ +module SpreeMultiVendor + module UserDecorator + def self.prepended(base) + base.has_many :orders, -> { without_vendor }, foreign_key: :user_id, class_name: 'Spree::Order' + base.has_many :completed_orders, -> { complete.without_vendor }, class_name: 'Spree::Order' + end + + def completed_orders_for_store(store) + orders.for_store(store).complete.without_vendor.order(currency: :desc) + end + end +end + +if Spree.user_class.present? + Spree.user_class.prepend(SpreeMultiVendor::UserDecorator) unless Spree.user_class.include?(SpreeMultiVendor::UserDecorator) +end diff --git a/app/models/spree_multi_vendor/variant_decorator.rb b/app/models/spree_multi_vendor/variant_decorator.rb new file mode 100644 index 00000000..a8fa37e9 --- /dev/null +++ b/app/models/spree_multi_vendor/variant_decorator.rb @@ -0,0 +1,28 @@ +module SpreeMultiVendor + module VariantDecorator + def self.prepended(base) + base.has_one :vendor, through: :product, class_name: 'Spree::Vendor' + + base.scope :with_vendor, ->(vendor) { joins(:product).merge(Spree::Product.with_vendor(vendor)) } + end + + def weight_unit + attributes['weight_unit'] || product.vendor&.weight_unit || Spree::Store.default.preferred_weight_unit + end + + # we need to disable SKU validation for multi-vendor + def disable_sku_validation? + true + end + + private + + def ensure_not_in_complete_orders + return if external_id.present? + + super + end + end +end + +Spree::Variant.prepend(SpreeMultiVendor::VariantDecorator) diff --git a/app/overrides/spree/admin/orders/_form.rb b/app/overrides/spree/admin/orders/_form.rb deleted file mode 100644 index ac623083..00000000 --- a/app/overrides/spree/admin/orders/_form.rb +++ /dev/null @@ -1,13 +0,0 @@ -Deface::Override.new( - virtual_path: 'spree/admin/orders/_form', - name: 'vendorize_shipments', - replace: "erb[loud]:contains('render partial: \"spree/admin/orders/shipment\", collection: @order.shipments.order(:created_at), locals: {order: order}')", - text: "<%= render partial: \"spree/admin/orders/shipment\", collection: (current_spree_vendor ? @order.shipments.for_vendor(current_spree_vendor).order(:created_at) : @order.shipments.order(:created_at)), locals: {order: order} %>" -) - -Deface::Override.new( - virtual_path: 'spree/admin/orders/_form', - name: 'display_vendor_total', - replace: "erb[loud]:contains('order.display_total')", - text: "<%= current_spree_vendor ? order.display_vendor_total(current_spree_vendor).to_html : order.display_total.to_html %>" -) diff --git a/app/overrides/spree/admin/orders/_line_items.rb b/app/overrides/spree/admin/orders/_line_items.rb deleted file mode 100644 index f6bbed6f..00000000 --- a/app/overrides/spree/admin/orders/_line_items.rb +++ /dev/null @@ -1,6 +0,0 @@ -Deface::Override.new( - virtual_path: 'spree/admin/orders/_line_items', - name: 'vendorize_line_items', - replace: "erb[silent]:contains('order.line_items.each do |item|')", - text: "<% (current_spree_vendor ? order.line_items.for_vendor(current_spree_vendor) : order.line_items).each do |item| %>" -) diff --git a/app/overrides/spree/admin/orders/_line_items_edit_form.rb b/app/overrides/spree/admin/orders/_line_items_edit_form.rb deleted file mode 100644 index 19ddc44a..00000000 --- a/app/overrides/spree/admin/orders/_line_items_edit_form.rb +++ /dev/null @@ -1,6 +0,0 @@ -Deface::Override.new( - virtual_path: 'spree/admin/orders/_line_items_edit_form', - name: 'display_vendor_total', - replace: "erb[loud]:contains('order.display_total')", - text: "<%= current_spree_vendor ? order.display_vendor_total(current_spree_vendor).to_html : order.display_total.to_html %>" -) diff --git a/app/overrides/spree/admin/orders/index.rb b/app/overrides/spree/admin/orders/index.rb deleted file mode 100644 index c68566aa..00000000 --- a/app/overrides/spree/admin/orders/index.rb +++ /dev/null @@ -1,6 +0,0 @@ -Deface::Override.new( - virtual_path: 'spree/admin/orders/index', - name: 'display_vendor_total', - replace: "erb[loud]:contains('order.display_total.to_html')", - text: "<%= current_spree_vendor ? order.display_vendor_total(current_spree_vendor).to_html : order.display_total.to_html %>" -) diff --git a/app/overrides/spree/admin/products/form.rb b/app/overrides/spree/admin/products/form.rb deleted file mode 100644 index d26dea05..00000000 --- a/app/overrides/spree/admin/products/form.rb +++ /dev/null @@ -1,29 +0,0 @@ -Deface::Override.new( - virtual_path: 'spree/admin/products/_form', - name: 'Enable vendors to manage product master price', - replace: 'div[data-hook="admin_product_form_price"]', - text: <<-HTML - <%= f.field_container :price, class: ['form-group'] do %> - <%= f.label :price, raw(Spree.t(:master_price) + content_tag(:span, ' *', class: "required")) %> - <%= f.text_field :price, value: number_to_currency(@product.price, unit: ''), class: 'form-control', disabled: (cannot? :update, Spree::Price) %> - <%= f.error_message_on :price %> - <% end %> - HTML -) - -Deface::Override.new( - virtual_path: 'spree/admin/products/_form', - name: 'Enable admin to menage product vendor', - insert_before: 'div[data-hook="admin_product_form_taxons"]', - text: <<-HTML - <% if current_spree_user.respond_to?(:has_spree_role?) && current_spree_user.has_spree_role?(:admin) %> -
- <%= f.field_container :vendor, class: ['form-group'] do %> - <%= f.label :vendor_id, Spree.t(:vendor) %> - <%= f.collection_select(:vendor_id, @vendors, :id, :name, { include_blank: Spree.t('match_choices.none') }, { class: 'select2' }) %> - <%= f.error_message_on :vendor %> - <% end %> -
- <% end %> - HTML -) diff --git a/app/overrides/spree/admin/products/new.rb b/app/overrides/spree/admin/products/new.rb deleted file mode 100644 index cfadedf9..00000000 --- a/app/overrides/spree/admin/products/new.rb +++ /dev/null @@ -1,16 +0,0 @@ -Deface::Override.new( - virtual_path: 'spree/admin/products/new', - name: 'Enable admin to create product with assigned vendor', - insert_after: 'div[data-hook="new_product_shipping_category"]', - text: <<-HTML - <% if current_spree_user.respond_to?(:has_spree_role?) && current_spree_user.has_spree_role?(:admin) %> -
- <%= f.field_container :vendor, class: ['form-group'] do %> - <%= f.label :vendor_id, Spree.t(:vendor) %> - <%= f.collection_select(:vendor_id, @vendors, :id, :name, { include_blank: Spree.t('match_choices.none') }, { class: 'select2' }) %> - <%= f.error_message_on :vendor %> - <% end %> -
- <% end %> - HTML -) diff --git a/app/overrides/spree/admin/shared/_order_summary.rb b/app/overrides/spree/admin/shared/_order_summary.rb deleted file mode 100644 index 1a0cfbbb..00000000 --- a/app/overrides/spree/admin/shared/_order_summary.rb +++ /dev/null @@ -1,35 +0,0 @@ -Deface::Override.new( - virtual_path: 'spree/admin/shared/_order_summary', - name: 'display_vendor_subtotal', - replace: "erb[loud]:contains('@order.display_item_total.to_html')", - text: "<%= current_spree_vendor ? @order.display_vendor_subtotal(current_spree_vendor).to_html : @order.display_item_total.to_html %>" -) - -Deface::Override.new( - virtual_path: 'spree/admin/shared/_order_summary', - name: 'display_vendor_ship_total', - replace: "erb[loud]:contains('@order.display_ship_total.to_html')", - text: "<%= current_spree_vendor ? @order.display_vendor_ship_total(current_spree_vendor).to_html : @order.display_ship_total.to_html %>" -) - -Deface::Override.new( - virtual_path: 'spree/admin/shared/_order_summary', - name: 'display_vendor_total', - replace: "erb[loud]:contains('@order.display_total.to_html')", - text: "<%= current_spree_vendor ? @order.display_vendor_total(current_spree_vendor).to_html : @order.display_total.to_html %>" -) -Deface::Override.new( - virtual_path: 'spree/admin/shared/_order_summary', - name: 'display_commission', - insert_after: 'td[id="item_total"]', - text: <<-HTML - - - <%= Spree.t(:commission) %> - - - <%= current_spree_vendor ? @order.display_vendor_commission(current_spree_vendor) : @order.display_order_commission %> - - - HTML -) diff --git a/app/overrides/spree/admin/users/_form.rb b/app/overrides/spree/admin/users/_form.rb deleted file mode 100644 index 6e18df19..00000000 --- a/app/overrides/spree/admin/users/_form.rb +++ /dev/null @@ -1,13 +0,0 @@ -Deface::Override.new( - virtual_path: 'spree/admin/users/_form', - name: 'Add vendor select in user form', - insert_bottom: 'div[data-hook="admin_user_form_roles"]', - text: <<-HTML - <% if current_spree_user.respond_to?(:has_spree_role?) && current_spree_user.has_spree_role?(:admin) %> - <%= f.field_container :vendor_ids, class: ['form-group'] do %> - <%= f.label :vendor_ids, plural_resource_name(Spree::Vendor) %> - <%= f.collection_select(:vendor_ids, Spree::Vendor.all, :id, :name, { }, { class: 'select2', multiple: true }) %> - <% end %> - <% end %> - HTML -) diff --git a/app/serializers/.gitkeep b/app/serializers/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/app/serializers/spree/api/v2/platform/order_serializer.rb b/app/serializers/spree/api/v2/platform/order_serializer.rb new file mode 100644 index 00000000..8a3fd18e --- /dev/null +++ b/app/serializers/spree/api/v2/platform/order_serializer.rb @@ -0,0 +1,36 @@ +module Spree + module Api + module V2 + module Platform + class OrderSerializer < BaseSerializer + attributes :number, :item_total, :total, :total_minus_store_credits, :display_total_minus_store_credits, :ship_total, :adjustment_total, :created_at, + :updated_at, :completed_at, :included_tax_total, :additional_tax_total, :display_additional_tax_total, + :display_included_tax_total, :tax_total, :currency, :state, :token, :email, + :display_item_total, :display_ship_total, :display_adjustment_total, :display_tax_total, + :promo_total, :display_promo_total, :item_count, :special_instructions, :display_total, + :pre_tax_item_amount, :display_pre_tax_item_amount, :pre_tax_total, :display_pre_tax_total, + :shipment_state, :payment_state, :public_metadata + + belongs_to :user + + belongs_to :bill_address, serializer: AddressSerializer + belongs_to :ship_address, serializer: AddressSerializer + + has_many :line_items + has_many :shipments + has_many :adjustments + has_many :all_adjustments, serializer: :adjustments, type: :adjustment + + has_many :order_promotions + + belongs_to :vendor, if: ->(order) { order.suborder? } + has_many :vendors, if: ->(order) { order.parent_order? } do |order| + order.vendor_orders.any? ? order.vendor_order_vendors : order.vendors + end + + has_many :vendor_orders, if: ->(order) { order.parent_order? }, serializer: :order, type: :order + end + end + end + end +end diff --git a/app/serializers/spree/api/v2/platform/product_serializer.rb b/app/serializers/spree/api/v2/platform/product_serializer.rb new file mode 100644 index 00000000..00d5a979 --- /dev/null +++ b/app/serializers/spree/api/v2/platform/product_serializer.rb @@ -0,0 +1,85 @@ +module Spree + module Api + module V2 + module Platform + class ProductSerializer < BaseSerializer + include DisplayMoneyHelper + + set_type :product + + attributes(*(Spree::Product.json_api_columns + ['sku', 'barcode', 'external_id'])) + + attribute :purchasable do |product| + product.purchasable? + end + + attribute :in_stock do |product| + product.in_stock? + end + + attribute :backorderable do |product| + product.backorderable? + end + + attribute :available do |product| + product.available? + end + + attribute :currency do |_product, params| + params[:currency] || Spree::Store.default.default_currency + end + + attribute :price do |product, params| + price(product, params[:currency]) + end + + attribute :display_price do |product, params| + display_price(product, params[:currency]) + end + + attribute :compare_at_price do |product, params| + compare_at_price(product, params[:currency]) + end + + attribute :display_compare_at_price do |product, params| + display_compare_at_price(product, params[:currency]) + end + + belongs_to :tax_category + + has_one :primary_variant, + object_method_name: :master, + id_method_name: :master_id, + record_type: :variant, + serializer: :variant + + has_one :default_variant, + object_method_name: :default_variant, + id_method_name: :default_variant_id, + record_type: :variant, + serializer: :variant + + has_many :variants + has_many :option_types + has_many :product_properties + + # all images from all variants + has_many :images, + object_method_name: :variant_images, + id_method_name: :variant_image_ids, + record_type: :image, + serializer: :image + + # Attributes added by Vendo + attribute :tags, &:tag_list + attribute :labels, &:label_list + + belongs_to :vendor + has_many :taxons, serializer: :taxon, record_type: :taxon + + belongs_to :shipping_category + end + end + end + end +end diff --git a/app/serializers/spree/api/v2/platform/vendor_serializer.rb b/app/serializers/spree/api/v2/platform/vendor_serializer.rb new file mode 100644 index 00000000..a1ecf42c --- /dev/null +++ b/app/serializers/spree/api/v2/platform/vendor_serializer.rb @@ -0,0 +1,21 @@ +module Spree + module Api + module V2 + module Platform + class VendorSerializer < BaseSerializer + include Spree::Api::V2::PublicMetafieldsConcern + + set_type :vendor + + attributes :name, :slug, :contact_person_email, :public_metadata + + attribute :about_us do |vendor| + vendor.about.to_plain_text + end + + has_many :policies, serializer: Spree::Api::Dependencies.storefront_policy_serializer.constantize, record_type: :policy + end + end + end + end +end diff --git a/app/serializers/spree/v2/storefront/order_serializer.rb b/app/serializers/spree/v2/storefront/order_serializer.rb new file mode 100644 index 00000000..5ccc442d --- /dev/null +++ b/app/serializers/spree/v2/storefront/order_serializer.rb @@ -0,0 +1,14 @@ +module Spree + module V2 + module Storefront + class OrderSerializer < CartSerializer + set_type :order + + has_many :line_items, &:all_line_items + has_many :variants, &:all_variants + has_many :shipments, &:all_shipments + has_many :vendors, &:all_vendors + end + end + end +end diff --git a/app/serializers/spree/v2/storefront/vendor_image_serializer.rb b/app/serializers/spree/v2/storefront/vendor_image_serializer.rb deleted file mode 100644 index ab318e95..00000000 --- a/app/serializers/spree/v2/storefront/vendor_image_serializer.rb +++ /dev/null @@ -1,11 +0,0 @@ -module Spree - module V2 - module Storefront - class VendorImageSerializer < BaseSerializer - set_type :vendor_image - - attributes :styles - end - end - end -end diff --git a/app/serializers/spree/v2/storefront/vendor_order_totals_serializer.rb b/app/serializers/spree/v2/storefront/vendor_order_totals_serializer.rb deleted file mode 100644 index 0ab7d246..00000000 --- a/app/serializers/spree/v2/storefront/vendor_order_totals_serializer.rb +++ /dev/null @@ -1,20 +0,0 @@ -module Spree - module V2 - module Storefront - class VendorOrderTotalsSerializer < BaseSerializer - set_type :vendor_totals - - cache_options store: nil - - attributes :name, :subtotal, :ship_total, :total, :display_subtotal, - :display_ship_total, :display_total, :promo_total, - :display_promo_total, :included_tax_total, - :display_included_tax_total, :additional_tax_total, - :display_additional_tax_total, :item_count, - :pre_tax_item_amount, :display_pre_tax_item_amount, - :pre_tax_ship_amount, :display_pre_tax_ship_amount, - :pre_tax_total, :display_pre_tax_total - end - end - end -end diff --git a/app/serializers/spree/v2/storefront/vendor_serializer.rb b/app/serializers/spree/v2/storefront/vendor_serializer.rb index aebc59ea..289b68e8 100644 --- a/app/serializers/spree/v2/storefront/vendor_serializer.rb +++ b/app/serializers/spree/v2/storefront/vendor_serializer.rb @@ -2,12 +2,38 @@ module Spree module V2 module Storefront class VendorSerializer < BaseSerializer + include Spree::Api::V2::PublicMetafieldsConcern + set_type :vendor - attributes :name, :about_us, :slug, :contact_us + attributes :name, :slug + + attribute :email do |vendor| + vendor.contact_person_email + end + + attribute :about_us do |vendor| + vendor.about.to_plain_text + end + + attribute :about_us_html do |vendor| + vendor.about.to_s + end + + attribute :logo_url do |vendor| + url_helpers.cdn_image_url(vendor.logo.attachment) if vendor.logo.present? && vendor.logo.attached? + end + + attribute :cover_photo_url do |vendor| + url_helpers.cdn_image_url(vendor.cover_photo.attachment) if vendor.cover_photo.present? && vendor.cover_photo.attached? + end + + has_many :products, + record_type: :product, + if: proc { |_vendor, params| params && params[:include_products] == true }, + &:active_products - has_one :image, serializer: :vendor_image - has_many :products + has_many :policies, serializer: Spree::Api::Dependencies.storefront_policy_serializer.constantize, record_type: :policy end end end diff --git a/app/serializers/spree_multi_vendor/cart_serializer_decorator.rb b/app/serializers/spree_multi_vendor/cart_serializer_decorator.rb deleted file mode 100644 index c3c6571d..00000000 --- a/app/serializers/spree_multi_vendor/cart_serializer_decorator.rb +++ /dev/null @@ -1,19 +0,0 @@ -module SpreeMultiVendor - module CartSerializerDecorator - def self.prepended(base) - base.has_many :vendors, - serializer: ::Spree::V2::Storefront::VendorSerializer, - record_type: :vendor, - id_method_name: :vendor_ids, - object_method_name: :vendor_list - - base.has_many :vendor_totals, - serializer: ::Spree::V2::Storefront::VendorOrderTotalsSerializer, - record_type: :vendor_totals, - id_method_name: :vendor_ids, - object_method_name: :vendor_totals - end - end -end - -Spree::V2::Storefront::CartSerializer.prepend(SpreeMultiVendor::CartSerializerDecorator) diff --git a/app/serializers/spree_multi_vendor/serializer_decorator.rb b/app/serializers/spree_multi_vendor/serializer_decorator.rb deleted file mode 100644 index 4a3bef89..00000000 --- a/app/serializers/spree_multi_vendor/serializer_decorator.rb +++ /dev/null @@ -1,23 +0,0 @@ -module SpreeMultiVendor - module SerializerDecorator - def self.prepended(base) - base.belongs_to :vendor, serializer: ::Spree::V2::Storefront::VendorSerializer - end - end -end - -SpreeMultiVendor.vendorized_models.each do |model| - serializer_class_name = "Spree::V2::Storefront::#{model.to_s.demodulize}Serializer".constantize - - serializer_class_name.prepend(SpreeMultiVendor::SerializerDecorator) -rescue -end - -# Variant model accesses Vendor via Product if not associated directly -unless SpreeMultiVendor.vendorized_models.include?(Spree::Variant) - Spree::V2::Storefront::VariantSerializer.prepend(SpreeMultiVendor::SerializerDecorator) -end - -unless SpreeMultiVendor.vendorized_models.include?(Spree::LineItem) - Spree::V2::Storefront::LineItemSerializer.prepend(SpreeMultiVendor::SerializerDecorator) -end diff --git a/app/serializers/spree_multi_vendor/v2/storefront/cart_serializer_decorator.rb b/app/serializers/spree_multi_vendor/v2/storefront/cart_serializer_decorator.rb new file mode 100644 index 00000000..f34684d4 --- /dev/null +++ b/app/serializers/spree_multi_vendor/v2/storefront/cart_serializer_decorator.rb @@ -0,0 +1,13 @@ +module SpreeMultiVendor + module V2 + module Storefront + module CartSerializerDecorator + def self.prepended(base) + base.has_many :vendors, &:all_vendors + end + end + end + end +end + +Spree::V2::Storefront::CartSerializer.prepend(SpreeMultiVendor::V2::Storefront::CartSerializerDecorator) diff --git a/app/serializers/spree_multi_vendor/v2/storefront/product_serializer_decorator.rb b/app/serializers/spree_multi_vendor/v2/storefront/product_serializer_decorator.rb new file mode 100644 index 00000000..f2fedafe --- /dev/null +++ b/app/serializers/spree_multi_vendor/v2/storefront/product_serializer_decorator.rb @@ -0,0 +1,18 @@ +module SpreeMultiVendor + module V2 + module Storefront + module ProductSerializerDecorator + def self.prepended(base) + base.belongs_to :vendor + base.belongs_to :shipping_category + + base.has_many :shipping_methods do |product| + product.shipping_methods.available_on_front_end + end + end + end + end + end +end + +Spree::V2::Storefront::ProductSerializer.prepend(SpreeMultiVendor::V2::Storefront::ProductSerializerDecorator) diff --git a/app/serializers/spree_multi_vendor/v2/storefront/vendorized_models_serializer_decorator.rb b/app/serializers/spree_multi_vendor/v2/storefront/vendorized_models_serializer_decorator.rb new file mode 100644 index 00000000..21cd1846 --- /dev/null +++ b/app/serializers/spree_multi_vendor/v2/storefront/vendorized_models_serializer_decorator.rb @@ -0,0 +1,21 @@ +module SpreeMultiVendor + module V2 + module Storefront + module VendorizedModelsSerializerDecorator + def self.prepended(base) + base.belongs_to :vendor, serializer: ::Spree::V2::Storefront::VendorSerializer + end + end + end + end +end + +Spree::V2::Storefront::ProductSerializer.prepend(SpreeMultiVendor::V2::Storefront::VendorizedModelsSerializerDecorator) + +# cart serializers +Spree::V2::Storefront::LineItemSerializer.prepend(SpreeMultiVendor::V2::Storefront::VendorizedModelsSerializerDecorator) +Spree::V2::Storefront::ShipmentSerializer.prepend(SpreeMultiVendor::V2::Storefront::VendorizedModelsSerializerDecorator) +Spree::V2::Storefront::StockLocationSerializer.prepend(SpreeMultiVendor::V2::Storefront::VendorizedModelsSerializerDecorator) + +# order serializers +Spree::V2::Storefront::OrderSerializer.prepend(SpreeMultiVendor::V2::Storefront::VendorizedModelsSerializerDecorator) diff --git a/app/services/spree/orders/create_vendor_order.rb b/app/services/spree/orders/create_vendor_order.rb new file mode 100644 index 00000000..15685988 --- /dev/null +++ b/app/services/spree/orders/create_vendor_order.rb @@ -0,0 +1,155 @@ +module Spree + module Orders + class CreateVendorOrder + prepend ::Spree::ServiceModule::Base + + def call(order:, vendor:, index:) + ActiveRecord::Base.transaction do + run :create_vendor_order + run :assign_addresses + run :move_line_items + run :move_shipments + run :create_marketplace_commissions + run :recalculate_totals + run :create_store_credit_payments + run :create_payments + run :mark_as_completed + run :create_vendor_order_notification + run :update_vendor_cache + end + end + + private + + def create_vendor_order(order:, vendor:, index:) + vendor_order = ::Spree::Order.create( + number: "#{order.number}-#{index}", + parent: order, + vendor: vendor, + user: order.user, + special_instructions: order.special_instructions, + store: order.store, + email: order.email, + payment_state: order.payment_state, + shipment_state: order.shipment_state, + last_ip_address: order.last_ip_address, + currency: order.currency + ) + + success(order: order, vendor: vendor, vendor_order: vendor_order, index: index) + end + + def assign_addresses(order:, vendor:, vendor_order:, index:) + vendor_order.update_columns( + ship_address_id: order.ship_address_id, + bill_address_id: order.bill_address_id + ) + + success(order: order, vendor: vendor, vendor_order: vendor_order.reload, index: index) + end + + def move_line_items(order:, vendor:, vendor_order:, index:) + order.line_items.with_vendor(vendor.id).each do |line_item| + line_item.update_column(:order_id, vendor_order.id) + line_item.adjustments.update_all(order_id: vendor_order.id) + line_item.inventory_units.update_all(order_id: vendor_order.id) + end + + success(order: order, vendor: vendor, vendor_order: vendor_order.reload, index: index) + end + + def move_shipments(order:, vendor:, vendor_order:, index:) + order.shipments.with_vendor(vendor.id).each do |s| + s.update_columns(order_id: vendor_order.id, address_id: vendor_order.ship_address_id) + s.adjustments.update_all(order_id: vendor_order.id) + end + + success(order: order, vendor: vendor, vendor_order: vendor_order.reload, index: index) + end + + def create_marketplace_commissions(order:, vendor:, vendor_order:, index:) + ::SpreeMultiVendor::Fees::CreateAllItemsFees.new(order: vendor_order).call unless order.covered_by_store_credit? + + success(order: order, vendor: vendor, vendor_order: vendor_order.reload, index: index) + end + + def recalculate_totals(order:, vendor:, vendor_order:, index:) + vendor_order.update_with_updater! + + success(order: order, vendor: vendor, vendor_order: vendor_order.reload, index: index) + end + + def create_store_credit_payments(order:, vendor:, vendor_order:, index:) + payments = order.payments.store_credits.valid.completed + payments.each do |payment| + if order.covered_by_store_credit? + amount = payment.amount * (vendor_order.total / order.total) + else + calculated_store_credit_result = SpreeMultiVendor::StoreCredits::CalculateVendorStoreCredit.call( + item_total: vendor_order.item_total, + order: order, + store_credit_total: payment.amount + ) + + amount = calculated_store_credit_result.value + end + + Spree::Payment.insert( + { + order_id: vendor_order.id, + vendor_id: vendor.id, + source_id: payment.source_id, + source_type: payment.source_type, + number: "#{payment.number}-#{index}", + amount: amount, + response_code: payment.response_code, + payment_method_id: payment.payment_method_id, + state: 'completed' + } + ) + end + + success(order: order, vendor: vendor, vendor_order: vendor_order.reload) + end + + def create_payments(order:, vendor:, vendor_order:) + payment = order.payments.not_store_credits.last + return success(order: order, vendor: vendor, vendor_order: vendor_order) unless payment.present? + + Spree::Payment.create!( + order: vendor_order, + source: payment.source, + amount: vendor_order.total_minus_store_credits, + response_code: payment.response_code, + payment_method: payment.payment_method, + state: 'completed' + ) + + success(order: order, vendor: vendor, vendor_order: vendor_order.reload) + end + + def mark_as_completed(order:, vendor:, vendor_order:) + vendor_order.update( + state: :complete, + completed_at: order.completed_at + ) + + success(order: order, vendor: vendor, vendor_order: vendor_order.reload) + end + + def create_vendor_order_notification(order:, vendor:, vendor_order:) + vendor_order.deliver_vendor_order_notification_email + + success(vendor_order: vendor_order.reload, vendor: vendor) + end + + def update_vendor_cache(vendor_order:, vendor:) + vendor.sales_total = vendor.orders.complete.sum(:total) + vendor.commission_total = vendor.orders.complete.sum(:platform_fee_total)&.abs + vendor.save! + + success(vendor_order) + end + end + end +end diff --git a/app/services/spree/orders/generate_commissions.rb b/app/services/spree/orders/generate_commissions.rb deleted file mode 100644 index e6dc2272..00000000 --- a/app/services/spree/orders/generate_commissions.rb +++ /dev/null @@ -1,34 +0,0 @@ -module Spree - module Orders - class GenerateCommissions - prepend Spree::ServiceModule::Base - - def call(order:) - ActiveRecord::Base.transaction do - run :generate_order_commissions - end - end - - private - - def generate_order_commissions(order:) - return failure(order) unless order.state == 'complete' - - order.vendor_list.each do |vendor| - amount = if vendor.commission_rate.zero? - 0.0 - else - order.vendor_pre_tax_item_amount(vendor) * vendor.commission_rate / 100 - end - - order.commissions.create!( - amount: amount, - vendor_id: vendor.id - ) - end - - success(order: order) - end - end - end -end diff --git a/app/services/spree/orders/split_by_vendor.rb b/app/services/spree/orders/split_by_vendor.rb new file mode 100644 index 00000000..3bc13a7a --- /dev/null +++ b/app/services/spree/orders/split_by_vendor.rb @@ -0,0 +1,44 @@ +module Spree + module Orders + class SplitByVendor + prepend ::Spree::ServiceModule::Base + + def call(order:) + ActiveRecord::Base.transaction do + run :check_if_parent_order + run :check_if_already_created + run :create_vendor_orders + run :mark_order_as_splitted + end + end + + private + + def check_if_parent_order(order:) + return failure(order) unless order.parent_order? + + success(order: order) + end + + def check_if_already_created(order:) + return failure(order) if order.vendor_orders.count == order.vendors.count + + success(order: order) + end + + def create_vendor_orders(order:) + order.vendors.where.not(id: order.vendor_orders.pluck(:vendor_id)).uniq.each_with_index do |vendor, index| + Spree::Orders::CreateVendorOrder.call(order: order, vendor: vendor, index: index + 1) + end + + success(order: order.reload) + end + + def mark_order_as_splitted(order:) + order.deliver_splitted_order_confirmation_email + + success(order: order) + end + end + end +end diff --git a/app/services/spree/refunds/calculate_additional_tax.rb b/app/services/spree/refunds/calculate_additional_tax.rb new file mode 100644 index 00000000..8ae77ae5 --- /dev/null +++ b/app/services/spree/refunds/calculate_additional_tax.rb @@ -0,0 +1,43 @@ +module Spree + module Refunds + class CalculateAdditionalTax + prepend ServiceModule::Base + + def call(refund:) + additional_tax = calculate_additional_tax(refund) + success(additional_tax) + end + + private + + def calculate_additional_tax(refund) + currency = refund.currency + + reimbursement = refund.reimbursement + order = refund.order + zero_money = Spree::Money.new(0, currency: currency) + + return zero_money if order.parent_order? + + if reimbursement + return Spree::Money.new(0, currency: currency) if reimbursement.custom_total? + + return_items = refund.return_items.includes(inventory_unit: :line_item) + return_items.sum(zero_money) do |return_item| + line_item = return_item.inventory_unit.line_item + quantity = return_item.inventory_unit.quantity + max_quantity = line_item.quantity + total_tax = line_item.additional_tax_total + + Spree::Money.new((quantity * total_tax / max_quantity.to_d).round(2, BigDecimal::ROUND_HALF_UP)) + end + else + line_item_tax = order.line_items.sum(zero_money, &:display_additional_tax_total) + shipment_tax = order.shipments.sum(zero_money, &:display_additional_tax_total) + + line_item_tax + shipment_tax + end + end + end + end +end diff --git a/app/services/spree_multi_vendor/api/products/filters_presenter_decorator.rb b/app/services/spree_multi_vendor/api/products/filters_presenter_decorator.rb new file mode 100644 index 00000000..73944c64 --- /dev/null +++ b/app/services/spree_multi_vendor/api/products/filters_presenter_decorator.rb @@ -0,0 +1,24 @@ +module SpreeMultiVendor + module Api + module Products + module FiltersPresenterDecorator + def to_h + option_values = Spree::OptionValues::FindAvailable.new(products_scope: products_for_filters).execute + option_values_presenters = Spree::Filters::OptionsPresenter.new(option_values_scope: option_values).to_a + { + option_types: option_values_presenters.map(&:to_h), + } + end + + private + + def find_products_for_filters(current_store, current_currency, params) + products = super(current_store, current_currency, params) + products.where(vendor: [nil, Spree::Vendor.approved]) + end + end + end + end +end + +Spree::Api::Products::FiltersPresenter.prepend(SpreeMultiVendor::Api::Products::FiltersPresenterDecorator) diff --git a/app/services/spree_multi_vendor/fees/calculate_item_fee.rb b/app/services/spree_multi_vendor/fees/calculate_item_fee.rb new file mode 100644 index 00000000..c3271465 --- /dev/null +++ b/app/services/spree_multi_vendor/fees/calculate_item_fee.rb @@ -0,0 +1,35 @@ +module SpreeMultiVendor + module Fees + class CalculateItemFee + prepend Spree::ServiceModule::Base + + def call(item:, rate: nil) + rate ||= item.product.platform_fee.presence || item.product.vendor&.platform_fee + return failure(item: item, rate: rate) if rate.nil? || rate.zero? + + promo_per_unit = item.promo_total.abs / item.quantity + store_credit_per_unit = calculate_store_credit_per_item(item) + + feeable_amount = item.price - promo_per_unit - store_credit_per_unit + fee_per_unit = (feeable_amount * rate / 100).round(2, BigDecimal::ROUND_HALF_UP) + fee_total = fee_per_unit * item.quantity + + success( + rate: rate, + fee_per_unit: fee_per_unit, + fee_total: fee_total, + store_credit_per_unit: store_credit_per_unit + ) + end + + private + + def calculate_store_credit_per_item(item) + order = item.order.parent || item.order + return 0 if order.nil? || order.total_applied_store_credit.zero? + + ((item.price / order.item_total) * order.total_applied_store_credit).round(2, BigDecimal::ROUND_HALF_UP) + end + end + end +end diff --git a/app/services/spree_multi_vendor/fees/create_all_items_fees.rb b/app/services/spree_multi_vendor/fees/create_all_items_fees.rb new file mode 100644 index 00000000..9ff21e21 --- /dev/null +++ b/app/services/spree_multi_vendor/fees/create_all_items_fees.rb @@ -0,0 +1,20 @@ +module SpreeMultiVendor + module Fees + class CreateAllItemsFees + def initialize(order:) + @order = order + end + + def call + vendor_line_items = order.reload.line_items.platform_feeable.includes(:product, :vendor) + items_fees = vendor_line_items.map { |item| SpreeMultiVendor::Fees::CreateItemFee.new(item: item).call } + + items_fees.compact + end + + private + + attr_reader :order + end + end +end diff --git a/app/services/spree_multi_vendor/fees/create_item_fee.rb b/app/services/spree_multi_vendor/fees/create_item_fee.rb new file mode 100644 index 00000000..ef70b665 --- /dev/null +++ b/app/services/spree_multi_vendor/fees/create_item_fee.rb @@ -0,0 +1,36 @@ +module SpreeMultiVendor + module Fees + class CreateItemFee + def initialize(item:, rate: nil) + @item = item + @rate = rate + end + + def call + calculations = CalculateItemFee.call(item: item, rate: rate) + return if calculations.failure? + + platform_fee = Spree::MarketplaceCommission.create!( + amount: calculations.value[:fee_total], + order: item.order, + feeable: item, + label: Spree::MarketplaceCommission::ITEM_LABEL, + rate: calculations.value[:rate], + active: true + ) + + item.update_columns( + platform_fee_per_unit: calculations.value[:fee_per_unit], + platform_fee_total: calculations.value[:fee_total], + platform_fee_rate: calculations.value[:rate] + ) + + platform_fee + end + + private + + attr_reader :item, :rate + end + end +end diff --git a/app/services/spree_multi_vendor/fees/create_reverse_item_fees.rb b/app/services/spree_multi_vendor/fees/create_reverse_item_fees.rb new file mode 100644 index 00000000..c1a652b8 --- /dev/null +++ b/app/services/spree_multi_vendor/fees/create_reverse_item_fees.rb @@ -0,0 +1,37 @@ +module SpreeMultiVendor + module Fees + class CreateReverseItemFees + def initialize(line_item:, inventory_unit:) + @line_item = line_item + @inventory_unit = inventory_unit + end + + def call + return if line_item.platform_fee_total.zero? + + amount = inventory_unit.quantity * line_item.platform_fee_per_unit + order = line_item.order + + fee = Spree::MarketplaceCommission.create!( + order: order, + feeable: line_item, + label: Spree::MarketplaceCommission::REVERSE_ITEM_LABEL, + rate: line_item.platform_fee_rate, + amount: amount + ) + + line_item.platform_fee_reverse_total += fee.amount + line_item.save! + + order.platform_fee_reverse_total += fee.amount + order.save! + + fee + end + + private + + attr_reader :line_item, :inventory_unit + end + end +end diff --git a/app/services/spree_multi_vendor/fees/create_reverse_order_fee.rb b/app/services/spree_multi_vendor/fees/create_reverse_order_fee.rb new file mode 100644 index 00000000..82b5fbb8 --- /dev/null +++ b/app/services/spree_multi_vendor/fees/create_reverse_order_fee.rb @@ -0,0 +1,37 @@ +module SpreeMultiVendor + module Fees + class CreateReverseOrderFee + def initialize(order:, refunded_amount:) + @order = order + @refunded_amount = refunded_amount + end + + def call + refunded_rate = refunded_amount / (order.total - order.ship_total) + + reverse_platform_fee_amount = (refunded_rate * order.platform_fee_total.abs).round(2) + remaining_platform_fee_amount = [order.platform_fee_total.abs - order.platform_fee_reverse_total.abs, 0.to_d].max + + reverse_platform_fee_amount = [reverse_platform_fee_amount, remaining_platform_fee_amount].min + + return if reverse_platform_fee_amount.zero? + + fee = Spree::MarketplaceCommission.create!( + feeable: order, + order: order, + amount: reverse_platform_fee_amount, + label: Spree::MarketplaceCommission::REVERSE_ORDER_LABEL + ) + + order.platform_fee_reverse_total += fee.amount + order.save! + + fee + end + + private + + attr_reader :order, :refunded_amount + end + end +end diff --git a/app/services/spree_multi_vendor/imports/row_processors/product_variant_decorator.rb b/app/services/spree_multi_vendor/imports/row_processors/product_variant_decorator.rb new file mode 100644 index 00000000..96f8b3f8 --- /dev/null +++ b/app/services/spree_multi_vendor/imports/row_processors/product_variant_decorator.rb @@ -0,0 +1,15 @@ +module SpreeMultiVendor + module Imports + module RowProcessors + module ProductVariantDecorator + def assign_attributes_to_product(product) + product = super(product) + product.vendor = import.owner if import.owner.is_a?(Spree::Vendor) + product + end + end + end + end +end + +Spree::Imports::RowProcessors::ProductVariant.prepend(SpreeMultiVendor::Imports::RowProcessors::ProductVariantDecorator) diff --git a/app/services/spree_multi_vendor/store_credits/calculate_vendor_store_credit.rb b/app/services/spree_multi_vendor/store_credits/calculate_vendor_store_credit.rb new file mode 100644 index 00000000..a75ff2fd --- /dev/null +++ b/app/services/spree_multi_vendor/store_credits/calculate_vendor_store_credit.rb @@ -0,0 +1,18 @@ +module SpreeMultiVendor + module StoreCredits + class CalculateVendorStoreCredit + prepend ::Spree::ServiceModule::Base + + def call(item_total:, order:, store_credit_total: nil) + parent_order = order.parent || order + store_credit_total ||= parent_order.total_applied_store_credit + + return success(0.to_d) if store_credit_total.zero? + + vendor_store_credit = ((item_total / parent_order.item_total) * store_credit_total).round(2, BigDecimal::ROUND_HALF_UP) + + success(vendor_store_credit) + end + end + end +end diff --git a/app/views/spree/admin/dashboard/_sidebar.html.erb b/app/views/spree/admin/dashboard/_sidebar.html.erb new file mode 100644 index 00000000..2c43dc60 --- /dev/null +++ b/app/views/spree/admin/dashboard/_sidebar.html.erb @@ -0,0 +1,6 @@ +<% if defined?(current_vendor) && current_vendor %> + +<% else %> + <%= render 'updater' %> + <%= render 'store_preview' %> +<% end %> \ No newline at end of file diff --git a/app/views/spree/admin/dashboard/_vendor_list_item.html.erb b/app/views/spree/admin/dashboard/_vendor_list_item.html.erb new file mode 100644 index 00000000..e75e0259 --- /dev/null +++ b/app/views/spree/admin/dashboard/_vendor_list_item.html.erb @@ -0,0 +1,7 @@ + + <%= vendor_logo(vendor, height: 30) || first_letter_icon(vendor.name, height: 30) %> + + <%= vendor.display_name.truncate(15) %> + + <%= vendor_status_badge(vendor) %> + diff --git a/app/views/spree/admin/dashboard/_vendor_onboarding_progress.html.erb b/app/views/spree/admin/dashboard/_vendor_onboarding_progress.html.erb new file mode 100644 index 00000000..67552bf5 --- /dev/null +++ b/app/views/spree/admin/dashboard/_vendor_onboarding_progress.html.erb @@ -0,0 +1,17 @@ +
+
+
+ Your overall setup progress + + <% if vendor.onboarding_completed? %> + <%= icon('check', class: 'text-success') %> + <% end %> + + + <%= vendor.onboarding_tasks_done %> of <%= vendor.onboarding_tasks_total %> steps done + + +
+ <%= progress_bar_component(vendor.onboarding_percentage) %> +
+
\ No newline at end of file diff --git a/app/views/spree/admin/dashboard/_vendor_onboarding_tasks.html.erb b/app/views/spree/admin/dashboard/_vendor_onboarding_tasks.html.erb new file mode 100644 index 00000000..23ccfe09 --- /dev/null +++ b/app/views/spree/admin/dashboard/_vendor_onboarding_tasks.html.erb @@ -0,0 +1,22 @@ +<% vendor.onboarding_tasks_list.each_with_index do |task, index| %> + <% done = vendor.onboarding_task_done?(task) %> + <% onboarding_error = params[:error].to_s == task.to_s %> + +
+
+ + <%= onboarding_check_circle(done) %> + <%= Spree.t("vendor_onboarding_tasks.#{task}") %> + <%= icon('chevron-down', class: 'arrow ml-auto') %> + +
+
+
+ <%= render "spree/admin/dashboard/vendor_onboarding_tasks/#{task}", + done: vendor.onboarding_task_done?(task), + pending: vendor.onboarding_task_pending?(task), + vendor: vendor %> +
+
+
+<% end %> \ No newline at end of file diff --git a/app/views/spree/admin/dashboard/_vendors.html.erb b/app/views/spree/admin/dashboard/_vendors.html.erb new file mode 100644 index 00000000..cc05dae0 --- /dev/null +++ b/app/views/spree/admin/dashboard/_vendors.html.erb @@ -0,0 +1,63 @@ +<% if can?(:manage, Spree::Vendor) && !current_vendor %> +
+
+ +
<%= Spree.t(:vendors) %>
+ <% if Spree::Vendor.any? %> + + <%= Spree::Vendor.count %> + + <% end %> +
+ <%= link_to Spree.t(:view_all), spree.admin_vendors_path, class: 'btn btn-sm btn-light' %> +
+
+ <% if Spree::Vendor.any? %> + +
+
+ <%= render 'spree/admin/dashboard/vendors_list', vendors: Spree::Vendor.includes(logo_attachment: :blob).order(created_at: :desc).first(5) %> +
+
+ <%= render 'spree/admin/dashboard/vendors_list', vendors: Spree::Vendor.approved.includes(logo_attachment: :blob).order(created_at: :desc).first(5) %> +
+
+ <%= render 'spree/admin/dashboard/vendors_list', vendors: Spree::Vendor.ready_for_review.includes(logo_attachment: :blob).order(created_at: :desc).first(5) %> +
+
+ <%= render 'spree/admin/dashboard/vendors_list', vendors: Spree::Vendor.onboarding.includes(logo_attachment: :blob).order(created_at: :desc).first(5) %> +
+
+ <% else %> +

+ <%= icon 'heart-handshake', height: 100, class: 'mb-3', style: 'opacity: 0.5' %> +
+ Invite your vendors +
+ Invite your vendors to sell together. +

+
+ <%= invite_vendor_button if can?(:create, Spree::Vendor) %> +
+ <% end %> +
+ <% if Spree::Vendor.count > 5 %> + + <% end %> +
+<% end %> \ No newline at end of file diff --git a/app/views/spree/admin/dashboard/_vendors_list.html.erb b/app/views/spree/admin/dashboard/_vendors_list.html.erb new file mode 100644 index 00000000..d29e6481 --- /dev/null +++ b/app/views/spree/admin/dashboard/_vendors_list.html.erb @@ -0,0 +1,10 @@ +<% if vendors.any? %> + +<% else %> +

+ No vendors matching your criteria. +

+<% end %> + diff --git a/app/views/spree/admin/dashboard/vendor_onboarding_tasks/_add_billing_address.html.erb b/app/views/spree/admin/dashboard/vendor_onboarding_tasks/_add_billing_address.html.erb new file mode 100644 index 00000000..498bb9de --- /dev/null +++ b/app/views/spree/admin/dashboard/vendor_onboarding_tasks/_add_billing_address.html.erb @@ -0,0 +1,12 @@ +
+ We need this information for your payouts and invoices. +
+ +<%= turbo_frame_tag :edit_billing_address, class: 'w-100' do %> + <% unless done %> + <%= render 'spree/admin/vendors/forms/billing_address', vendor: vendor %> + <% else %> + <%= render 'spree/admin/shared/address', address: vendor.billing_address %> + <%= link_to_with_icon 'edit', Spree.t(:edit), spree.edit_billing_address_admin_vendor_path(vendor), class: 'btn btn-light mt-3' %> + <% end %> +<% end %> diff --git a/app/views/spree/admin/dashboard/vendor_onboarding_tasks/_add_policies.html.erb b/app/views/spree/admin/dashboard/vendor_onboarding_tasks/_add_policies.html.erb new file mode 100644 index 00000000..7355f6d3 --- /dev/null +++ b/app/views/spree/admin/dashboard/vendor_onboarding_tasks/_add_policies.html.erb @@ -0,0 +1,14 @@ +

+ <% if done %> + You've added policies, good job! + <% else %> + You need to supply all policies. + <% end %> +

+
+ <% if done %> + <%= link_to_with_icon 'edit', Spree.t(:edit), spree.admin_policies_path, class: 'btn btn-light', data: { turbo: false } %> + <% else %> + <%= link_to Spree.t('vendor_onboarding_tasks.add_policies'), spree.admin_policies_path, class: 'btn btn-primary', data: { turbo: false } %> + <% end %> +
\ No newline at end of file diff --git a/app/views/spree/admin/dashboard/vendor_onboarding_tasks/_add_returns_address.html.erb b/app/views/spree/admin/dashboard/vendor_onboarding_tasks/_add_returns_address.html.erb new file mode 100644 index 00000000..ae9fd9e3 --- /dev/null +++ b/app/views/spree/admin/dashboard/vendor_onboarding_tasks/_add_returns_address.html.erb @@ -0,0 +1,20 @@ +
+ We need to know where to send returns +
+<%= turbo_frame_tag :add_returns_address, class: 'w-100' do %> + <% unless done %> + <%= render 'spree/admin/vendors/forms/returns_address', vendor: vendor %> + <% else %> + <%= render 'spree/admin/shared/address', address: vendor.returns_address %> + <% if vendor.returns_address.latitude.present? && vendor.returns_address.longitude.present? %> +
+ <%= js_map [{ + latitude: vendor.returns_address.latitude, + longitude: vendor.returns_address.longitude, + label: vendor.name, + }], zoom: 15, controls: true, height: '300px' %> +
+ <% end %> + <%= link_to_with_icon 'edit', Spree.t(:edit), spree.edit_returns_address_admin_vendor_path(vendor), class: 'btn btn-light mt-3' %> + <% end %> +<% end %> diff --git a/app/views/spree/admin/dashboard/vendor_onboarding_tasks/_add_shipping_methods.html.erb b/app/views/spree/admin/dashboard/vendor_onboarding_tasks/_add_shipping_methods.html.erb new file mode 100644 index 00000000..a07e8c73 --- /dev/null +++ b/app/views/spree/admin/dashboard/vendor_onboarding_tasks/_add_shipping_methods.html.erb @@ -0,0 +1,14 @@ +
+ <% if done %> + You've added shipping methods, good job! + <% else %> + You need to setup your shipping methods. + <% end %> +
+
+ <% if done %> + <%= link_to_with_icon 'edit', Spree.t(:edit), spree.admin_shipping_methods_path, class: 'btn btn-light', data: { turbo: false } %> + <% else %> + <%= link_to 'Set shipping methods', spree.new_admin_shipping_method_path, class: 'btn btn-primary', data: { turbo: false } %> + <% end %> +
diff --git a/app/views/spree/admin/dashboard/vendor_onboarding_tasks/_set_shipping_rates.html.erb b/app/views/spree/admin/dashboard/vendor_onboarding_tasks/_set_shipping_rates.html.erb new file mode 100644 index 00000000..dac5a6f1 --- /dev/null +++ b/app/views/spree/admin/dashboard/vendor_onboarding_tasks/_set_shipping_rates.html.erb @@ -0,0 +1,14 @@ +
+ <% if done %> + You've set up your shipping methods, good job! + <% else %> + You need to setup shipping methods and confirm shipping rates. + <% end %> +
+
+ <% if done %> + <%= link_to_with_icon 'edit', Spree.t(:edit), spree.admin_shipping_methods_path, class: 'btn btn-light', data: { turbo: false } %> + <% else %> + <%= link_to 'Manage Shipping Methods', spree.admin_shipping_methods_path, class: 'btn btn-primary', data: { turbo: false } %> + <% end %> +
diff --git a/app/views/spree/admin/marketplace_settings/edit.html.erb b/app/views/spree/admin/marketplace_settings/edit.html.erb new file mode 100644 index 00000000..20691335 --- /dev/null +++ b/app/views/spree/admin/marketplace_settings/edit.html.erb @@ -0,0 +1,107 @@ +<%= content_for :page_title do %> + <%= Spree.t('admin.marketplace') %> +<% end %> + +<%= content_for :page_actions do %> + <%= turbo_save_button_tag Spree.t('actions.update'), class: 'btn btn-primary', form: "edit_store_#{current_store.id}" %> +<% end %> + +<%= form_for current_store, url: spree.admin_marketplace_settings_path, method: :patch do |f| %> +
+
+
+
+
Vendor Commission rate
+
+
+

+ This is the default commission rate, you can override this value for + each Partner, Category x Vendor and Vendor product. +

+
+

+ This change will only apply to newly invited + <%= link_to "Partners", spree.admin_vendors_path, class: "alert-link" %>. It will not change the commission rate for existing Partners. +

+

It will not change the commission rate for existing Partners.

+
+
+ <%= f.label :preferred_platform_fee, "Commission rate" %> +
+ <%= f.number_field :preferred_platform_fee, + step: 0.1, + min: 0, + class: "form-control-plaintext pl-0" %> + % +
+ <%= f.error_message_on :preferred_platform_fee %> +
+
+
+ + <% if defined?(SpreeStripe::Gateway) %> +
+
+
Vendor Payouts
+
+
+

+ This controls how often your Vendors will be able to request a payout + in Stripe dashboard. +

+
+

+ Changing this option will also change payout schedules for your + existing Vendors with the payout schedule set to + Use marketplace default interval. +

+
+
+ <%= f.label :preferred_vendor_payouts_schedule_interval, + Spree.t(:preferred_vendor_payouts_schedule_interval) %> + <%= f.select :preferred_vendor_payouts_schedule_interval, + options_for_select( + stripe_store_payouts_schedule_interval_options, + f.object.preferred_vendor_payouts_schedule_interval, + ), + {}, + { class: "custom-select" } %> + <%= f.error_message_on :preferred_vendor_payouts_schedule_interval %> +
+
+
+ <% end %> + +
+
+
Shopify App
+
+
+
+
+ <%= f.label :shopify_app_image, class: 'form-label' %> +
+ <%= render 'active_storage/upload_form', form: f, field_name: :shopify_app_image, width: 600, height: 300, crop: true %> +

+ This image will be displayed in the Shopify App when merchants install the app. +

+
+
+
+ +
+
+
Terms of Service
+
+
+
+ <%= f.rich_text_area :vendor_terms_of_service %> +
+

+ This will be displayed to Vendors when they are invited to the marketplace. +

+
+
+
+
+<% end %> \ No newline at end of file diff --git a/app/views/spree/admin/orders/_marketplace_commissions.html.erb b/app/views/spree/admin/orders/_marketplace_commissions.html.erb new file mode 100644 index 00000000..bd72ce6c --- /dev/null +++ b/app/views/spree/admin/orders/_marketplace_commissions.html.erb @@ -0,0 +1,22 @@ + diff --git a/app/views/spree/admin/orders/_marketplace_suborder.html.erb b/app/views/spree/admin/orders/_marketplace_suborder.html.erb new file mode 100644 index 00000000..7e710a2e --- /dev/null +++ b/app/views/spree/admin/orders/_marketplace_suborder.html.erb @@ -0,0 +1,31 @@ +
  • + <%= link_to suborder.number, spree.edit_admin_order_path(suborder), class: 'text-dark font-weight-bold d-block w-20' %> +
    + <% if suborder.vendor.present? %> + <%= vendor_logo_link(suborder.vendor, height: 20) %> + <% end %> +
    + <% if suborder.payment_state %> +
    + <%= order_payment_state(suborder) %> + <% if suborder.transfer_failed_error.present? %> + + Transfer failed + + <% end %> +
    + <% end %> + <% if suborder.shipment_state %> +
    + <%= order_shipment_state(suborder) %> +
    + <% end %> + + <%= suborder.display_total %> + + + <%= Spree.t(:commission_total) %>: + <%= suborder.display_platform_fee_total.abs %> + + +
  • diff --git a/app/views/spree/admin/orders/_marketplace_suborders.html.erb b/app/views/spree/admin/orders/_marketplace_suborders.html.erb new file mode 100644 index 00000000..f2a0a3bf --- /dev/null +++ b/app/views/spree/admin/orders/_marketplace_suborders.html.erb @@ -0,0 +1,18 @@ +<% if @suborders&.any? %> +
    +
    +
    + <%= Spree.t(:suborders) %> +
    +
    +
    +
    +

    For each of your Vendors fulfilling this order we will create a suborder. You can see the status of each suborder below.

    +

    By clicking on the suborder number you can preview the order as your partner sees it.

    +
    + +
    +
    +<% end %> diff --git a/app/views/spree/admin/orders/_marketplace_summary.html.erb b/app/views/spree/admin/orders/_marketplace_summary.html.erb new file mode 100644 index 00000000..55c20454 --- /dev/null +++ b/app/views/spree/admin/orders/_marketplace_summary.html.erb @@ -0,0 +1,22 @@ +<% if @order.marketplace_commissions.any? || marketplace_commission_on_vendor_orders?(@order) %> +
  • + + + <%= Spree.t(:commission_total) %> + + + + <%= link_to '#marketplace-commissions', class: 'btn btn-light btn-sm mr-2', data: { toggle: 'collapse' } do %> + <%= Spree.t(:show_details) %> + <%= icon('selector', height: 12, class: 'mr-0 ml-1') %> + <% end %> + + <%= order_summary_marketplace_commission_money(@order).to_html %> + + + + <%= render 'spree/admin/orders/marketplace_commissions', + marketplace_commissions: order_summary_marketplace_commissions(@order), + suborder: @order.suborder? %> +
  • +<% end %> \ No newline at end of file diff --git a/app/views/spree/admin/orders/_vendor_filter.html.erb b/app/views/spree/admin/orders/_vendor_filter.html.erb new file mode 100644 index 00000000..c72b5f66 --- /dev/null +++ b/app/views/spree/admin/orders/_vendor_filter.html.erb @@ -0,0 +1,14 @@ +<% if defined?(current_vendor) && !current_vendor && !@vendor %> +
    +
    + <%= f.label :vendor_orders_vendor_id_eq, Spree.t(:vendor) %> + <%= tom_select_tag "q[vendor_orders_vendor_id_eq]", + active_option: params.dig(:q, :vendor_orders_vendor_id_eq), + options: vendor_options, + select_data: { + filters_target: "input", + }, + url: spree.select_options_admin_vendors_path(format: :json) %> +
    +
    +<% end %> diff --git a/app/views/spree/admin/orders/marketplace_commissions/_parent_order_commission.html.erb b/app/views/spree/admin/orders/marketplace_commissions/_parent_order_commission.html.erb new file mode 100644 index 00000000..6b474720 --- /dev/null +++ b/app/views/spree/admin/orders/marketplace_commissions/_parent_order_commission.html.erb @@ -0,0 +1,16 @@ +<%= marketplace_commission.display_amount %> +(<%= number_to_percentage(marketplace_commission.rate) %>) + +<% if marketplace_commission.discounted? %> + + <%= Spree.t(:original_rate) %>: + <%= number_to_percentage(marketplace_commission.rate) %> + +<% end %> + +<% if marketplace_commission.discounted? %> + + <%= Spree.t(:discount) %>: + <%= marketplace_commission.display_discount %> + +<% end %> diff --git a/app/views/spree/admin/orders/marketplace_commissions/_sub_order_commission.html.erb b/app/views/spree/admin/orders/marketplace_commissions/_sub_order_commission.html.erb new file mode 100644 index 00000000..a3fb8372 --- /dev/null +++ b/app/views/spree/admin/orders/marketplace_commissions/_sub_order_commission.html.erb @@ -0,0 +1,14 @@ +<%= marketplace_commission.display_amount %> +(<%= number_to_percentage(marketplace_commission.rate_after_discount) %>) + +<% if marketplace_commission.discounted? %> + + <%= Spree.t(:original_rate) %>: + <%= number_to_percentage(marketplace_commission.rate) %> + + + + <%= Spree.t(:discount) %>: + <%= marketplace_commission.display_discount %> + +<% end %> diff --git a/app/views/spree/admin/products/_empty_list.html.erb b/app/views/spree/admin/products/_empty_list.html.erb new file mode 100644 index 00000000..0e83e10f --- /dev/null +++ b/app/views/spree/admin/products/_empty_list.html.erb @@ -0,0 +1,9 @@ +
    + <% if current_vendor %> + <% if current_vendor.external_store&.present? %> + Please give us a moment to sync your products. Time needed for this depends on the number of your products. + <% end %> + <% else %> + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::Product)) %>. + <% end %> +
    diff --git a/app/views/spree/admin/products/_vendor_filter.html.erb b/app/views/spree/admin/products/_vendor_filter.html.erb new file mode 100644 index 00000000..da4c8675 --- /dev/null +++ b/app/views/spree/admin/products/_vendor_filter.html.erb @@ -0,0 +1,19 @@ +<%= f.hidden_field :vendor_id_eq, value: @vendor.id if @vendor.present? %> + +<% unless current_vendor.present? %> +
    + <%= f.label :vendor_id_in, Spree.t(:vendor) %> + <% @vendors = Spree::Vendor.where(id: params[:q].presence&.[](:vendor_id_in)).pluck(:name, :id) if params[:q].presence&.[]( + :vendor_id_in, + ) %> + <%= tom_select_tag "q[vendor_id_in]", + preloaded_options: @vendors, + multiple: true, + active_option: params[:q].presence&.[](:vendor_id_in), + url: select_options_admin_vendors_path(format: :json), + select_class: "w-100", + select_data: { + filters_target: "input", + } %> +
    +<% end %> diff --git a/app/views/spree/admin/products/form/_platform_fee.html.erb b/app/views/spree/admin/products/form/_platform_fee.html.erb new file mode 100644 index 00000000..3fd7d3c0 --- /dev/null +++ b/app/views/spree/admin/products/form/_platform_fee.html.erb @@ -0,0 +1,16 @@ +<% if f.object.vendor.present? && can?(:manage_commission, f.object) %> +
    +
    +
    <%= Spree.t(:platform_fee) %>
    +
    +
    +
    + <%= f.label :platform_fee %> +
    + <%= f.text_field :platform_fee, value: number_with_precision(f.object.platform_fee, precision: 2), class: 'form-control-plaintext pl-0' %> + <%= f.label :platform_fee, '%', { class: 'form-control-plaintext w-auto border-left' } %> +
    +
    +
    +
    +<% end %> \ No newline at end of file diff --git a/app/views/spree/admin/shared/_marketplace_products_nav.html.erb b/app/views/spree/admin/shared/_marketplace_products_nav.html.erb new file mode 100644 index 00000000..d619d985 --- /dev/null +++ b/app/views/spree/admin/shared/_marketplace_products_nav.html.erb @@ -0,0 +1,3 @@ +<% if defined?(Spree::ExternalCategory) && can?(:manage, Spree::ExternalCategory) %> + <%= nav_item('Category mapper', spree.admin_external_categories_path) %> +<% end %> \ No newline at end of file diff --git a/app/views/spree/admin/shared/_marketplace_settings_nav.html.erb b/app/views/spree/admin/shared/_marketplace_settings_nav.html.erb new file mode 100644 index 00000000..15721df8 --- /dev/null +++ b/app/views/spree/admin/shared/_marketplace_settings_nav.html.erb @@ -0,0 +1 @@ +<%= nav_item(Spree.t('admin.marketplace'), spree.edit_admin_marketplace_settings_path, icon: 'heart-handshake') if can?(:manage, current_store) %> \ No newline at end of file diff --git a/app/views/spree/admin/shared/_vendor_badge.html.erb b/app/views/spree/admin/shared/_vendor_badge.html.erb new file mode 100644 index 00000000..404fb345 --- /dev/null +++ b/app/views/spree/admin/shared/_vendor_badge.html.erb @@ -0,0 +1,7 @@ +<%= link_to spree.admin_vendor_path(vendor), class: 'd-flex rounded-lg hover-light py-1 px-2 align-items-center text-dark', data: { 'turbo-frame': '_top' } do %> + <%= vendor_logo(vendor, height: 30) %> + + <%= vendor.name %> + + <%= vendor_status_badge(vendor) %> +<% end %> diff --git a/app/views/spree/admin/shared/_vendor_faq.html.erb b/app/views/spree/admin/shared/_vendor_faq.html.erb new file mode 100644 index 00000000..c96a885f --- /dev/null +++ b/app/views/spree/admin/shared/_vendor_faq.html.erb @@ -0,0 +1,62 @@ +

    Quick FAQ

    +
    +
    +
    + +
    + +
    +
    +

    + Your products are automatically pulled from your Shopify store. You can add new products to your Shopify store and they will be automatically added to your Vendo shop. +

    +
    +
    +
    +
    +
    + +
    +
    +
    +

    + Orders are automatically sent to your Shopify or WooCommerce store. We support all + of your fulfillment apps. +

    +
    +
    +
    +
    +
    + +
    +
    +
    +

    + Returns are automatically sent to your Shopify store. You can check your Shopify store for new returns. +

    +
    +
    +
    +
    +
    + +
    +
    +
    +

    + Refunds and cancellations performed in your Shopify store will be automatically reflected in <%= current_store.name %> and + the customer will automatically receive the proper refund. +

    +
    +
    +
    +
    diff --git a/app/views/spree/admin/shared/filters/_amount_range_filter.html.erb b/app/views/spree/admin/shared/filters/_amount_range_filter.html.erb new file mode 100644 index 00000000..693df1c8 --- /dev/null +++ b/app/views/spree/admin/shared/filters/_amount_range_filter.html.erb @@ -0,0 +1,25 @@ +
    +
    + <%= label_tag :amount_range, Spree.t(:amount_range) %> +
    +
    + <%= form.label :amount_gteq, class: "d-none" %> + <%= form.number_field :amount_gteq, + class: "form-control", + placeholder: Spree.t(:from), + data: { + filters_target: :input, + } %> +
    +
    + <%= form.label :amount_lteq, class: "d-none" %> + <%= form.number_field :amount_lteq, + class: "form-control", + placeholder: Spree.t(:to), + data: { + filters_target: :input, + } %> +
    +
    +
    +
    \ No newline at end of file diff --git a/app/views/spree/admin/shared/filters/_created_date_range_filter.html.erb b/app/views/spree/admin/shared/filters/_created_date_range_filter.html.erb new file mode 100644 index 00000000..17728f76 --- /dev/null +++ b/app/views/spree/admin/shared/filters/_created_date_range_filter.html.erb @@ -0,0 +1,17 @@ +
    +
    + <%= label_tag :q_created_at_gt, Spree.t(:date_range) %> +
    +
    + <%= form.date_field :created_at_gt, class: 'form-control', value: params.dig(:q, :created_at_gt), max: params.dig(:q, :created_at_lt), data: { + filters_target: :input, + } %> +
    +
    + <%= form.date_field :created_at_lt, class: 'form-control', value: params.dig(:q, :created_at_lt), data: { + filters_target: :input, + } %> +
    +
    +
    +
    \ No newline at end of file diff --git a/app/views/spree/admin/shared/sidebar/_vendor_dropdown.html.erb b/app/views/spree/admin/shared/sidebar/_vendor_dropdown.html.erb new file mode 100644 index 00000000..507c13a8 --- /dev/null +++ b/app/views/spree/admin/shared/sidebar/_vendor_dropdown.html.erb @@ -0,0 +1,8 @@ +
    + +
    diff --git a/app/views/spree/admin/shared/sidebar/_vendor_nav.html.erb b/app/views/spree/admin/shared/sidebar/_vendor_nav.html.erb new file mode 100644 index 00000000..7aa38d8f --- /dev/null +++ b/app/views/spree/admin/shared/sidebar/_vendor_nav.html.erb @@ -0,0 +1,5 @@ +<% if settings_area? %> + <%= render_navigation(:vendor_settings) %> +<% else %> + <%= render_navigation(:vendor_sidebar) %> +<% end %> diff --git a/app/views/spree/admin/shared/sidebar/_vendors_nav.html.erb b/app/views/spree/admin/shared/sidebar/_vendors_nav.html.erb new file mode 100644 index 00000000..57b77d3d --- /dev/null +++ b/app/views/spree/admin/shared/sidebar/_vendors_nav.html.erb @@ -0,0 +1,19 @@ +<% vendors_active = %w[vendors].include?(controller_name) %> + \ No newline at end of file diff --git a/app/views/spree/admin/shipping_methods/_actions.html.erb b/app/views/spree/admin/shipping_methods/_actions.html.erb new file mode 100644 index 00000000..39ac2c52 --- /dev/null +++ b/app/views/spree/admin/shipping_methods/_actions.html.erb @@ -0,0 +1,3 @@ +<% if current_vendor.present? && !current_vendor.preferred_shipping_rates_confirmed && current_vendor.shipping_methods.any? %> + <%= link_to_with_icon 'check', 'Confirm Shipping Rates', spree.confirm_shipping_rates_admin_vendor_path(current_vendor), class: 'btn btn-primary ml-2', data: { turbo_method: :patch, turbo_confirm: Spree.t(:are_you_sure) }, id: 'confirm-shipping-rates-button' %> +<% end %> \ No newline at end of file diff --git a/app/views/spree/admin/taxons/rule_forms/_vendor.html.erb b/app/views/spree/admin/taxons/rule_forms/_vendor.html.erb new file mode 100644 index 00000000..7f0b5098 --- /dev/null +++ b/app/views/spree/admin/taxons/rule_forms/_vendor.html.erb @@ -0,0 +1 @@ +<%= rule_form.select :value, Spree::Vendor.approved.pluck(:name, :id), { include_blank: true }, data: { controller: 'autocomplete-select' } %> diff --git a/app/views/spree/admin/translations/vendors/_form.html.erb b/app/views/spree/admin/translations/vendors/_form.html.erb new file mode 100644 index 00000000..f9760e34 --- /dev/null +++ b/app/views/spree/admin/translations/vendors/_form.html.erb @@ -0,0 +1,10 @@ +<%# locals: (f:, resource:, locale:) %> + +<% resource.class.translatable_fields.each do |field| %> + <% case field %> + <% when :about %> + <%= render "spree/admin/translations/translation_rows/rich_textarea_row", f: f, field: field, locale: locale %> + <% else %> + <%= render "spree/admin/translations/translation_rows/text_field_row", f: f, field: field, locale: locale %> + <% end %> +<% end %> diff --git a/app/views/spree/admin/vendor_settings/edit.html.erb b/app/views/spree/admin/vendor_settings/edit.html.erb index 2ddea477..4513949f 100644 --- a/app/views/spree/admin/vendor_settings/edit.html.erb +++ b/app/views/spree/admin/vendor_settings/edit.html.erb @@ -1,45 +1,69 @@ -<% content_for :page_title do %> - <%= @vendor.name %> +<%= content_for :page_title do %> + <%= Spree.t(:settings) %> <% end %> -<%= form_for [:admin, @vendor], url: admin_vendor_settings_url do |f| %> -
    - <%= render partial: 'spree/admin/shared/error_messages', locals: { target: @vendor } %> -
    - <%= f.field_container :name do %> - <%= f.label :name %> - <%= f.text_field :name, class: 'form-control' %> - <% end %> -
    -
    - <%= f.field_container :image do %> - <%= f.label :image %> - <%= f.file_field :image %> - <%= image_tag main_app.url_for(@vendor.image.url(:small)) if @vendor.image %> - <% end %> -
    -
    - <%= f.field_container :about_us do %> - <%= f.label :about_us %> - <%= f.text_area :about_us, class: 'form-control' %> - <% end %> -
    -
    - <%= f.field_container :contact_us do %> - <%= f.label :contact_us %> - <%= f.text_area :contact_us, class: 'form-control' %> - <% end %> -
    -
    - <%= f.field_container :notification_email do %> - <%= f.label :notification_email %> - <%= f.email_field :notification_email, class: 'form-control' %> - <% end %> -
    -
    - <%= button Spree.t('actions.update'), 'save' %> - <%= Spree.t(:or) %> - <%= button_link_to Spree.t('actions.cancel'), admin_vendor_settings_url, icon: 'delete' %> +<%= content_for :page_actions do %> + <%= turbo_save_button_tag Spree.t('actions.update'), form: 'edit_vendor_settings' %> +<% end %> + +<%= form_with model: current_vendor, url: spree.admin_vendor_settings_path, id: 'edit_vendor_settings' do |f| %> +
    +
    +
    +
    +
    + <%= Spree.t(:general_settings) %> +
    +
    +
    +
    + <%= f.label :name %> + <%= f.text_field :name, class: 'form-control' %> +
    +
    + <%= f.label :billing_email %> + <%= f.email_field :billing_email, class: 'form-control' %> +
    +
    +
    + +
    +
    +
    + <%= Spree.t(:billing_address) %> +
    +
    +
    + <% f.object.build_billing_address(country: current_store.default_country) unless f.object.billing_address %> + <%= f.fields_for :billing_address do |billing_address_form| %> + <%= render 'spree/addresses/form', + address_name: 'billing_address', + address_form: billing_address_form, + address_type: 'billing', + address: f.object.billing_address, + available_countries: Spree::Country.all %> + <% end %> +
    +
    + +
    +
    +
    + <%= Spree.t(:returns_address) %> +
    +
    +
    + <% f.object.build_returns_address(country: current_store.default_country) unless f.object.returns_address %> + <%= f.fields_for :returns_address do |returns_address_form| %> + <%= render 'spree/addresses/form', + address_name: 'returns_address', + address_form: returns_address_form, + address_type: 'shipping', + address: f.object.returns_address, + available_countries: Spree::Country.all %> + <% end %> +
    +
    -
    + <% end %> diff --git a/app/views/spree/admin/vendors/_billing.html.erb b/app/views/spree/admin/vendors/_billing.html.erb new file mode 100644 index 00000000..cc395ad2 --- /dev/null +++ b/app/views/spree/admin/vendors/_billing.html.erb @@ -0,0 +1,36 @@ +
    +
    + +
    +
    +
    + <% if @vendor.billing_email.present? %> +

    <%= mail_to @vendor.billing_email %>

    + <% end %> + + <% if @vendor.billing_address.present? %> + <%= render 'spree/admin/shared/address', address: @vendor.billing_address %> + <% else %> +

    + <%= Spree.t(:not_provided) %> +

    + <% end %> +
    +
    +
    diff --git a/app/views/spree/admin/vendors/_card.html.erb b/app/views/spree/admin/vendors/_card.html.erb new file mode 100644 index 00000000..4210dc6b --- /dev/null +++ b/app/views/spree/admin/vendors/_card.html.erb @@ -0,0 +1,45 @@ +<% cache spree_base_cache_scope.call(@vendor) do %> + <%= turbo_frame_tag "vendor_card_#{@vendor.id}" do %> +
    +
    + + <%= link_to_with_icon 'edit', Spree.t(:edit), spree.edit_brand_admin_vendor_path(@vendor), class: 'btn btn-light', data: { turbo_frame: :dialog_modal_lg } %> + +
    + <% if @vendor.cover_photo.attached? && @vendor.cover_photo.variable? %> + <%= image_tag( + main_app.cdn_image_url( + @vendor.cover_photo.variant(spree_image_variant_options(resize_to_fill: [800, 300])) + ), + class: 'card-img-top bg-light', + alt: @vendor.name, + style: 'height: 260px' + ) %> + <% else %> +
    + <% end %> +
    +
    +
    +
    + <% if @vendor.logo.attached? && @vendor.logo.variable? %> + <%= image_tag( + main_app.cdn_image_url( + @vendor.logo.variant(spree_image_variant_options(resize_to_limit: [150, 150])) + ), + alt: @vendor.name, + class: 'rounded-lg' + ) %> + <% end %> +
    +
    +
    + <% if @vendor.about.present? %> + <%= @vendor.about %> + <% end %> +
    +
    +
    +
    + <% end %> +<% end %> diff --git a/app/views/spree/admin/vendors/_contact.html.erb b/app/views/spree/admin/vendors/_contact.html.erb new file mode 100644 index 00000000..3e45e294 --- /dev/null +++ b/app/views/spree/admin/vendors/_contact.html.erb @@ -0,0 +1,7 @@ +
  • + <%= render_avatar(contact, width: 48, height: 48, class: 'avatar mb-0 m-1 rounded-lg float-left mr-3') %> +
    + <%= contact.name&.truncate(40) %> + <%= contact.email.truncate(40) %> +
    +
  • \ No newline at end of file diff --git a/app/views/spree/admin/vendors/_display_commission_rate.html.erb b/app/views/spree/admin/vendors/_display_commission_rate.html.erb new file mode 100644 index 00000000..3f0c9550 --- /dev/null +++ b/app/views/spree/admin/vendors/_display_commission_rate.html.erb @@ -0,0 +1,12 @@ +<%= turbo_frame_tag "vendor_commision_rate_#{vendor.id}" do %> +
    + Commission rate +
    + <%= vendor.platform_fee %>% +   + <%= link_to_with_icon 'edit', Spree.t(:edit), spree.edit_commission_rate_admin_vendor_path(@vendor), \ + class: 'btn btn-light btn-sm', no_text: true if can?(:admin, Spree::Store) + %> +
    + <% end %> +
    diff --git a/app/views/spree/admin/vendors/_display_payouts_schedule.html.erb b/app/views/spree/admin/vendors/_display_payouts_schedule.html.erb new file mode 100644 index 00000000..f24e50ca --- /dev/null +++ b/app/views/spree/admin/vendors/_display_payouts_schedule.html.erb @@ -0,0 +1,7 @@ +<%= turbo_frame_tag "vendor_payouts_schedule_#{@vendor.id}" do %> +
    + <%= @stripe_account.payouts_schedule&.capitalize %> + + <%= link_to_with_icon 'edit', Spree.t(:edit), spree.edit_payouts_schedule_admin_vendor_path(@vendor), class: 'btn btn-light btn-sm' %> +
    +<% end if @stripe_account %> diff --git a/app/views/spree/admin/vendors/_filters.html.erb b/app/views/spree/admin/vendors/_filters.html.erb new file mode 100644 index 00000000..2fd8ab3c --- /dev/null +++ b/app/views/spree/admin/vendors/_filters.html.erb @@ -0,0 +1,10 @@ +<%= search_form_for [:admin, @search], class: "filter-wrap", data: {controller: "filters"} do |f| %> +
    + <%= render 'spree/admin/shared/filters_search_bar', param: :name_cont, label: Spree.t(:name) %> + + <%= render "spree/admin/vendors/table_filter_dropdown" %> +
    + <%= render "spree/admin/shared/filter_badge_template" %> + +
    +<% end %> diff --git a/app/views/spree/admin/vendors/_form.html.erb b/app/views/spree/admin/vendors/_form.html.erb deleted file mode 100644 index 11ea2414..00000000 --- a/app/views/spree/admin/vendors/_form.html.erb +++ /dev/null @@ -1,34 +0,0 @@ -<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @vendor } %> -
    - <%= f.field_container :name do %> - <%= f.label :name, raw(Spree.t(:name) + required_span_tag) %> - <%= f.text_field :name, class: 'form-control' %> - <% end %> - <%= f.field_container :about_us do %> - <%= f.label :about_us %> - <%= f.text_area :about_us, class: 'form-control' %> - <% end %> - <%= f.field_container :contact_us do %> - <%= f.label :contact_us %> - <%= f.text_area :contact_us, class: 'form-control' %> - <% end %> - <%= f.field_container :state do %> - <%= f.label :state, Spree.t(:state) %> - <%= f.select :state, - options_for_select(vendor_state_options, selected: @vendor.state), - {}, class: 'select2', required: :required %> - <% end %> - <%= f.field_container :image do %> - <%= f.label :image %> - <%= f.file_field :image %> - <%= image_tag main_app.url_for(@vendor.image.url(:small)) if @vendor.image %> - <% end %> - <%= f.field_container :commission_rate do %> - <%= f.label :commission_rate %> - <%= f.number_field :commission_rate, step: '0.01', class: 'form-control' %> - <% end %> - <%= f.field_container :notification_email do%> - <%= f.label :notification_email %> - <%= f.email_field :notification_email, class: 'form-control' %> - <% end %> -
    diff --git a/app/views/spree/admin/vendors/_header.html.erb b/app/views/spree/admin/vendors/_header.html.erb new file mode 100644 index 00000000..03e32c43 --- /dev/null +++ b/app/views/spree/admin/vendors/_header.html.erb @@ -0,0 +1,24 @@ +<% content_for :page_title do %> + <%= page_header_back_button spree.admin_vendors_url, @vendor unless current_vendor.present? %> + <%= @vendor.display_name %> + <%= vendor_status_badge(@vendor, class: 'small d-none d-lg-inline') %> +<% end %> + +<% content_for :page_actions do %> + <% if @vendor.can_approve? %> + <%= link_to_with_icon 'check', Spree.t('actions.approve'), spree.approve_admin_vendor_path(@vendor), { class: "btn btn-primary mr-2", data: { turbo_method: :patch, turbo_confirm: Spree.t(:are_you_sure) } } if can?(:approve, @vendor) %> + <% end %> +<% end if can?(:edit, @vendor) && controller_name == 'vendors' %> + +<% content_for :page_actions_dropdown do %> + <% if @vendor.can_reject? %> + <%= link_to_with_icon 'ban', Spree.t('actions.reject'), spree.reject_admin_vendor_path(@vendor), { class: "dropdown-item", data: { turbo_method: :patch, turbo_confirm: Spree.t(:are_you_sure) } } if can?(:reject, @vendor) %> + <% end %> + <% if @vendor.can_suspend? %> + <%= link_to_with_icon 'clock-pause', Spree.t('actions.suspend'), spree.suspend_admin_vendor_path(@vendor), { class: "dropdown-item", data: { turbo_method: :patch, turbo_confirm: Spree.t(:are_you_sure) } } if can?(:suspend, @vendor) %> + <% end %> + <% if can?(:delete, @vendor) && @vendor.can_be_deleted? %> + <%= link_to_with_icon 'archive', Spree.t('actions.archive'), spree.archive_admin_vendor_path(@vendor), { class: "dropdown-item", data: { turbo_method: :patch, turbo_confirm: Spree.t(:are_you_sure) } } %> + <% end %> + +<% end if can?(:edit, @vendor) && controller_name == 'vendors' %> diff --git a/app/views/spree/admin/vendors/_onboarding_status.html.erb b/app/views/spree/admin/vendors/_onboarding_status.html.erb new file mode 100644 index 00000000..2f4462ca --- /dev/null +++ b/app/views/spree/admin/vendors/_onboarding_status.html.erb @@ -0,0 +1,45 @@ +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    diff --git a/app/views/spree/admin/vendors/_returns_address.html.erb b/app/views/spree/admin/vendors/_returns_address.html.erb new file mode 100644 index 00000000..fb7e7879 --- /dev/null +++ b/app/views/spree/admin/vendors/_returns_address.html.erb @@ -0,0 +1,34 @@ +
    +
    + +
    +
    +
    + <% if @vendor.returns_address.present? %> + <%= render 'spree/admin/shared/address', address: @vendor.returns_address %> +
    + <%= render 'spree/admin/shared/map', map_object: @vendor.returns_address, label: @vendor.name %> + <% else %> +

    + Not yet provided by partner +

    + <% end %> +
    +
    +
    diff --git a/app/views/spree/admin/vendors/_stripe.html.erb b/app/views/spree/admin/vendors/_stripe.html.erb new file mode 100644 index 00000000..5b56c7fe --- /dev/null +++ b/app/views/spree/admin/vendors/_stripe.html.erb @@ -0,0 +1,59 @@ +<% if defined?(SpreeStripe::Gateway) %> +
    +
    + +
    +
    +
    + <% if @stripe_account.present? %> + + +
    + <%= text_field_tag :stripe_connect_id, @stripe_account.account_id, readonly: true, class: 'form-control-plaintext py-0 text-muted', data: { clipboard_target: 'source' } %> + +
    + + <%= link_to "https://dashboard.stripe.com/connect/accounts/#{@stripe_account.account_id}/activity", class: 'btn btn-secondary btn-sm' do %> + Open Stripe dashboard + <%= icon('external-link', class: 'ml-2') %> + <% end %> + +
    + + + <%= render 'display_payouts_schedule' %> + <% else %> +

    + Not connected +

    + <% end %> +
    +
    +
    +<% end%> diff --git a/app/views/spree/admin/vendors/_table_filter_dropdown.html.erb b/app/views/spree/admin/vendors/_table_filter_dropdown.html.erb new file mode 100644 index 00000000..aebfec67 --- /dev/null +++ b/app/views/spree/admin/vendors/_table_filter_dropdown.html.erb @@ -0,0 +1,38 @@ + diff --git a/app/views/spree/admin/vendors/_tabs.html.erb b/app/views/spree/admin/vendors/_tabs.html.erb index ac444a77..8b423c92 100644 --- a/app/views/spree/admin/vendors/_tabs.html.erb +++ b/app/views/spree/admin/vendors/_tabs.html.erb @@ -1,4 +1,104 @@ -<% content_for :page_title do %> - <%= page_header_back_button spree.admin_vendors_path %> - <%= @vendor.name %> -<% end %> + + +
    +
    +
    + <%= form_tag spree.admin_dashboard_analytics_path, method: :get, data: { turbo_frame: :analytics } do %> + <%= hidden_field_tag :vendor_id, vendor.id %> + <%= render 'spree/admin/shared/calendar_range_picker', + date_from_value: params[:date_from] || 1.month.ago.end_of_day, + date_to_value: params[:date_to] || Time.zone.now.beginning_of_day %> + <% end %> +
    + + <%= turbo_frame_tag :analytics, src: spree.admin_dashboard_analytics_path(vendor_id: vendor.id), loading: :lazy do %> + <%= render 'spree/admin/shared/analytics_loader_skeleton' %> + <% end %> +
    +
    +
    + <%= turbo_frame_tag :products_list, src: spree.admin_vendor_products_path(vendor, frame_name: :products_list), loading: :lazy, autoscroll: true, data: { autoscroll_block: :nearest, autoscroll_behavior: :smooth } do %> + <%= render 'spree/admin/shared/spinner' %> + <% end %> +
    +
    +
    +
    + <%= turbo_frame_tag :orders_list, src: spree.admin_vendor_orders_path(vendor, frame_name: :orders_list), loading: :lazy, autoscroll: true, data: { autoscroll_block: :nearest, autoscroll_behavior: :smooth } do %> + <%= render 'spree/admin/shared/spinner' %> + <% end %> +
    +
    +
    +
    +
    + + + + + + + + + + <%= render partial: 'spree/admin/admin_users/admin_user', collection: @vendor_users %> + +
    <%= Spree.t(:user) %><%= Spree.t(:roles) %><%= Spree.t(:created_at) %>
    +
    +
    +
    +
    +
    +
    + + + + + + + + + + + + + <%= render collection: @vendor.invitations, partial: 'spree/admin/invitations/invitation', as: :invitation %> + +
    <%= Spree.t(:email) %><%= I18n.t('activerecord.attributes.spree/invitation.inviter') %><%= Spree.t(:roles) %><%= Spree.t(:status) %><%= Spree.t(:created_at) %>
    +
    +
    +
    +
    +
    +
    + + + <%= render 'spree/admin/policies/table_header' %> + + + <%= render partial: 'spree/admin/policies/table_row', collection: @vendor.policies, as: :policy %> + +
    +
    +
    +
    +
    diff --git a/app/views/spree/admin/vendors/_totals.html.erb b/app/views/spree/admin/vendors/_totals.html.erb new file mode 100644 index 00000000..c3c2b658 --- /dev/null +++ b/app/views/spree/admin/vendors/_totals.html.erb @@ -0,0 +1,43 @@ +
    + +
    diff --git a/app/views/spree/admin/vendors/_vendor.html.erb b/app/views/spree/admin/vendors/_vendor.html.erb new file mode 100644 index 00000000..29ab2269 --- /dev/null +++ b/app/views/spree/admin/vendors/_vendor.html.erb @@ -0,0 +1,51 @@ + + + <%= vendor_logo_link(vendor, data: { row_link_target: 'link' }) %> + + + <%= vendor_status_badge(vendor) %> + + + + <%= vendor.onboarding_tasks_done %> of <%= vendor.onboarding_tasks_total %> steps done + +
    +
    +
    + + + <% if vendor.respond_to?(:external_store) && vendor.external_store.present? %> + <% if vendor.external_store.shopify? %> + <%= image_tag 'shopify-icon.png', height: 20, alt: 'Shopify', title: 'Shopify', class: 'with-tip' %> + <% elsif vendor.external_store.woo_commerce? %> + <%= image_tag 'WooCommerce_logo.svg', height: 20, alt: 'Woo', title: 'Woo Commerce', class: 'with-tip' %> + <% end %> + <% else %> + <%= Spree.t(:not_available) %> + <% end %> + + + <% if vendor.products_count&.positive? %> + <%= vendor.products_count %> + <% else %> + <%= Spree.t(:not_available) %> + <% end %> + + + <% if vendor.sales_total.positive? %> + <%= vendor.display_sales_total %> + <% else %> + <%= Spree.t(:not_available) %> + <% end %> + + + <% if vendor.commission_total.positive? %> + <%= vendor.display_commission_total %> + <% else %> + <%= Spree.t(:not_available) %> + <% end %> + + + <%= local_time_ago(vendor.created_at, class: 'with-tip') %> + + diff --git a/app/views/spree/admin/vendors/create.turbo_stream.erb b/app/views/spree/admin/vendors/create.turbo_stream.erb new file mode 100644 index 00000000..90c38d22 --- /dev/null +++ b/app/views/spree/admin/vendors/create.turbo_stream.erb @@ -0,0 +1,2 @@ +<%= turbo_render_alerts %> +<%= turbo_close_modal %> diff --git a/app/views/spree/admin/vendors/edit.html.erb b/app/views/spree/admin/vendors/edit.html.erb index d650d737..b089507e 100644 --- a/app/views/spree/admin/vendors/edit.html.erb +++ b/app/views/spree/admin/vendors/edit.html.erb @@ -1,8 +1,23 @@ -<%= render partial: 'spree/admin/vendors/tabs', locals: { current: :details } %> +<% content_for :page_title do %> + <%= page_header_back_button spree.admin_vendor_path(@vendor) %> + <%= @vendor.name %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @vendor } %> <%= form_for [:admin, @vendor] do |f| %> -
    - <%= render partial: 'form', locals: { f: f } %> - <%= render partial: 'spree/admin/shared/edit_resource_links' %> -
    +
    +
    + <%= f.label :name %> + <%= f.text_field :name, class: 'form-control', required: true, autofocus: true, placeholder: 'eg. Apple' %> +
    +
    + <%= f.label :billing_email %> + <%= f.email_field :billing_email, class: 'form-control', placeholder: 'eg. tim@apple.com' %> +
    +
    +
    + <%= turbo_save_button_tag Spree.t('actions.update') %> + <%= link_to Spree.t('actions.discard'), spree.admin_vendor_path(@vendor), class: 'btn btn-light' %> +
    <% end %> diff --git a/app/views/spree/admin/vendors/edit_billing_address.html.erb b/app/views/spree/admin/vendors/edit_billing_address.html.erb new file mode 100644 index 00000000..84b6cb9d --- /dev/null +++ b/app/views/spree/admin/vendors/edit_billing_address.html.erb @@ -0,0 +1,3 @@ +<%= turbo_frame_tag :edit_billing_address do %> + <%= render 'spree/admin/vendors/forms/billing_address', vendor: @vendor %> +<% end %> diff --git a/app/views/spree/admin/vendors/edit_brand.html.erb b/app/views/spree/admin/vendors/edit_brand.html.erb new file mode 100644 index 00000000..3d74313f --- /dev/null +++ b/app/views/spree/admin/vendors/edit_brand.html.erb @@ -0,0 +1,47 @@ +<%= turbo_frame_tag :dialog_modal_lg do %> + <%= form_for [:admin, @vendor] do |f| %> + + <% end %> +<% end %> \ No newline at end of file diff --git a/app/views/spree/admin/vendors/edit_commission_rate.html.erb b/app/views/spree/admin/vendors/edit_commission_rate.html.erb new file mode 100644 index 00000000..05642f65 --- /dev/null +++ b/app/views/spree/admin/vendors/edit_commission_rate.html.erb @@ -0,0 +1,22 @@ +<%= turbo_frame_tag "vendor_commision_rate_#{@vendor.id}" do %> +
    + This will be the default commission rate for all products from this partner unless + you specify a different rate for a specific product. +
    + <%= form_for [:admin, @vendor] do |f| %> +
    + <%= f.label :platform_fee, 'Commission Rate' %> +
    + <%= f.number_field :platform_fee, class: 'form-control', step: 0.01, min: 0 %> +
    + % +
    +
    + <%= f.error_message_on :platform_fee %> +
    +
    + <%= turbo_save_button_tag %> + <%= link_to Spree.t('actions.discard'), spree.admin_vendor_path(@vendor, display_commission_rate: true), class: 'btn btn-light ml-2' %> +
    + <% end %> +<% end %> diff --git a/app/views/spree/admin/vendors/edit_payouts_schedule.html.erb b/app/views/spree/admin/vendors/edit_payouts_schedule.html.erb new file mode 100644 index 00000000..4e2b56a4 --- /dev/null +++ b/app/views/spree/admin/vendors/edit_payouts_schedule.html.erb @@ -0,0 +1,19 @@ +<%= turbo_frame_tag "vendor_payouts_schedule_#{@vendor.id}" do %> + <%= form_for [:admin, @vendor] do |f| %> +
    + <%= f.select :payouts_schedule_interval, + options_for_select(stripe_vendor_payouts_schedule_interval_options, @vendor.stripe_account.payouts_interval), + {}, + { class: 'custom-select' } %> + <%= f.error_message_on :payouts_schedule_interval %> +

    + Marketplace default interval is set in + <%= link_to Spree.t(:general_settings), spree.edit_admin_store_path(section: 'general-settings', anchor: 'partners-payouts', target: '_blank'), data: { turbo_frame: '_top' } %> +

    +
    +
    + <%= turbo_save_button_tag %> + <%= link_to Spree.t('actions.discard'), spree.admin_vendor_path(@vendor, display_payouts_schedule: true), class: 'btn btn-light ml-2' %> +
    + <% end %> +<% end if current_store.respond_to?(:stripe_gateway) && current_store.stripe_gateway.present? && @vendor.respond_to?(:stripe_account) && @vendor.stripe_account.present? %> diff --git a/app/views/spree/admin/vendors/edit_returns_address.html.erb b/app/views/spree/admin/vendors/edit_returns_address.html.erb new file mode 100644 index 00000000..9e8cbc49 --- /dev/null +++ b/app/views/spree/admin/vendors/edit_returns_address.html.erb @@ -0,0 +1,3 @@ +<%= turbo_frame_tag :add_returns_address do %> + <%= render 'spree/admin/vendors/forms/returns_address', vendor: @vendor %> +<% end %> diff --git a/app/views/spree/admin/vendors/edit_returns_policy.html.erb b/app/views/spree/admin/vendors/edit_returns_policy.html.erb new file mode 100644 index 00000000..22c20f79 --- /dev/null +++ b/app/views/spree/admin/vendors/edit_returns_policy.html.erb @@ -0,0 +1,34 @@ +<%= content_for(:page_title) do %> + <%= Spree.t(:returns) %> +<% end %> + +
    +
    +
    +
    +
    + <%= Spree.t(:returns_policy) %> +
    +
    +
    + <%= turbo_frame_tag :add_policies do %> + <%= render 'spree/admin/vendors/forms/returns_policy', vendor: @vendor %> + <% end %> +
    +
    +
    +
    +
    +
    +
    + <%= Spree.t(:returns_address) %> +
    +
    +
    + <%= turbo_frame_tag :add_returns_address do %> + <%= render 'spree/admin/vendors/forms/returns_address', vendor: @vendor %> + <% end %> +
    +
    +
    +
    \ No newline at end of file diff --git a/app/views/spree/admin/vendors/forms/_billing_address.html.erb b/app/views/spree/admin/vendors/forms/_billing_address.html.erb new file mode 100644 index 00000000..831af2eb --- /dev/null +++ b/app/views/spree/admin/vendors/forms/_billing_address.html.erb @@ -0,0 +1,13 @@ +<%= form_for [:admin, vendor], url: spree.admin_vendor_path(vendor) do |f| %> + <% vendor.build_billing_address(country: current_store.default_country) unless vendor.billing_address %> + <%= f.fields_for :billing_address do |billing_address_form| %> + <%= render 'spree/addresses/form', + address_name: 'billing_address', + address_form: billing_address_form, + address_type: 'billing', + address: vendor.billing_address %> + <% end %> +
    + <%= turbo_save_button_tag %> +
    +<% end %> \ No newline at end of file diff --git a/app/views/spree/admin/vendors/forms/_operator_settings.html.erb b/app/views/spree/admin/vendors/forms/_operator_settings.html.erb new file mode 100644 index 00000000..ccbc5596 --- /dev/null +++ b/app/views/spree/admin/vendors/forms/_operator_settings.html.erb @@ -0,0 +1,34 @@ +
    +
    +
    <%= Spree.t('vendor.operator_settings') %>
    +

    + This information isn't visible for Partners, only for the Operator. +

    +
    +
    + <% if stripe_gateway.present? && @stripe_account.present? %> +
    + <%= f.label :payouts_schedule_interval, 'Payouts schedule' %> + <%= f.select :payouts_schedule_interval, + options_for_select(stripe_vendor_payouts_schedule_interval_options, @stripe_account.payouts_interval), + {}, + { class: 'custom-select' } %> + <%= f.error_message_on :payouts_schedule_interval %> +

    + Marketplace default interval is set in + <%= link_to 'General Settings', spree.edit_admin_store_path(section: 'general-settings', anchor: 'partners-payouts') %> +

    +
    + <% end %> +
    + <%= f.label :platform_fee, 'Commission Rate' %> +
    + <%= f.number_field :platform_fee, class: 'form-control', step: 0.01, min: 0 %> +
    + % +
    +
    + <%= f.error_message_on :platform_fee %> +
    +
    +
    diff --git a/app/views/spree/admin/vendors/forms/_returns_address.html.erb b/app/views/spree/admin/vendors/forms/_returns_address.html.erb new file mode 100644 index 00000000..13d57777 --- /dev/null +++ b/app/views/spree/admin/vendors/forms/_returns_address.html.erb @@ -0,0 +1,13 @@ +<%= form_for [:admin, vendor], url: spree.admin_vendor_path(vendor) do |f| %> + <% vendor.build_returns_address(country: current_store.default_country) unless vendor.returns_address %> + <%= f.fields_for :returns_address do |returns_address_form| %> + <%= render 'spree/addresses/form', + address_name: 'returns_address', + address_form: returns_address_form, + address_type: 'shipping', + address: vendor.returns_address %> + <% end %> +
    + <%= turbo_save_button_tag %> +
    +<% end %> \ No newline at end of file diff --git a/app/views/spree/admin/vendors/forms/_returns_policy.html.erb b/app/views/spree/admin/vendors/forms/_returns_policy.html.erb new file mode 100644 index 00000000..d4df773c --- /dev/null +++ b/app/views/spree/admin/vendors/forms/_returns_policy.html.erb @@ -0,0 +1,10 @@ +<%= form_for [:admin, vendor], url: spree.admin_vendor_path(vendor) do |f| %> +
    +
    + <%= f.rich_text_area :returns_policy, class: 'overflow-auto w-100', style: 'height: 300px' %> +
    +
    +
    + <%= turbo_save_button_tag %> +
    +<% end %> \ No newline at end of file diff --git a/app/views/spree/admin/vendors/index.csv.erb b/app/views/spree/admin/vendors/index.csv.erb new file mode 100644 index 00000000..2777b0e8 --- /dev/null +++ b/app/views/spree/admin/vendors/index.csv.erb @@ -0,0 +1,46 @@ +<%- headers = [ + 'Name', + 'Status', + 'Onboarding progress', + 'Integration', + 'Products (All)', + 'Live Products', + 'Revenue', + 'Commission (Rate)', + 'Commission (Total)', + 'Join Date', + 'Contact Person Email', + 'Billing name', + 'Billing email', + 'Returns Address Name', + 'Returns Address Street', + 'Returns Address City', + 'Returns Address Postal Code', + 'Returns Address State', + 'Returns Address Country' + ] +-%> +<%= CSV.generate_line(headers).strip %> +<%- @vendors.each do |vendor| -%> +<%= CSV.generate_line([ + vendor.display_name, + vendor.state, + number_to_percentage(vendor.onboarding_percentage), + vendor.respond_to?(:external_store) ? vendor.external_store&.display_name : '', + vendor.products_count, + vendor.products.active.count, + vendor.sales_total, + number_to_percentage(vendor.platform_fee), + vendor.commission_total, + vendor.created_at, + vendor.contact_person_email, + vendor.billing_address&.company, + vendor.billing_email, + vendor.returns_address&.company, + vendor.returns_address&.street, + vendor.returns_address&.city, + vendor.returns_address&.zipcode, + vendor.returns_address&.state_text, + vendor.returns_address&.country_iso, + ]).strip.html_safe %> +<%- end -%> diff --git a/app/views/spree/admin/vendors/index.html.erb b/app/views/spree/admin/vendors/index.html.erb index 6142a086..d7552dd4 100644 --- a/app/views/spree/admin/vendors/index.html.erb +++ b/app/views/spree/admin/vendors/index.html.erb @@ -1,77 +1,55 @@ <% content_for :page_title do %> - <%= plural_resource_name(Spree::Vendor) %> + <%= Spree.t(:vendors) %> <% end %> <% content_for :page_actions do %> - <%= button_link_to Spree.t(:new_vendor), new_object_url, { class: "btn-success", icon: 'add', id: 'admin_new_vendor' } %> -<% end if can?(:create, Spree::Vendor) %> + <%= link_to spree.admin_vendors_path(q: params[:q].to_unsafe_hash, page: params[:page], per_page: params[:per_page], format: :csv), class: 'btn btn-light bg-transparent with-tip d-none d-lg-inline-flex', title: 'Export Vendors to a CSV file. To filter specific Vendors use "Filters" or status tabs before clicking this button' do %> + <%= icon 'table-export' %> + <%= Spree.t(:export) %> + <% end %> -<% content_for :table_filter do %> -
    - <%= search_form_for [:admin, @search] do |f| %> -
    -
    -
    - <%= label_tag :q_name_cont, Spree.t(:name) %> - <%= f.text_field :name_cont, tabindex: 1, class: "form-control js-quick-search-target" %> -
    -
    -
    -
    - <%= label_tag :q_state_in, Spree.t(:state) %> - <%= f.select :state_in, options_for_select(vendor_state_options), { multiple: 'please select' }, class: 'select2', multiple: true %> -
    -
    -
    -
    - <%= button Spree.t(:filter_results), 'search' %> -
    - <% end %> -
    + <%= invite_vendor_button if can?(:create, Spree::Vendor) %> <% end %> -<%= render partial: 'spree/admin/shared/index_table_options', locals: { collection: @collection } %> +
    + <%= render "filters" %> -<% if @vendors.any? %> - - - - - - - - - - - <% @vendors.each do |vendor| %> - - - - - - - <% end %> - -
    - <%= Spree.t(:user) %> - - <%= sort_link @search, :name, I18n.t(:name, scope: 'activerecord.attributes.spree/vendor') %> - - <%= sort_link @search, :state, I18n.t(:state, scope: 'activerecord.attributes.spree/vendor') %> -
    - <%= vendor.users.first.email rescue nil %> - - <%= vendor.name %> - - <%= vendor.state.humanize.downcase %> - - - <%= link_to_edit vendor, no_text: true, class: 'edit' if can?(:edit, vendor) %> -
    -<% else %> -
    - <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::Vendor)) %> -
    -<% end %> - -<%= render partial: 'spree/admin/shared/index_table_options', locals: { collection: @collection } %> + <% if @vendors.any? %> +
    + + + + + + + + + + + + + <%= render partial: "spree/admin/vendors/vendor", collection: @vendors, cached: spree_base_cache_scope %> + +
    + <%= sort_link @search, :name, I18n.t(:name, scope: "activerecord.attributes.spree/vendor") %> + + <%= sort_link @search, :state, I18n.t(:state, scope: "activerecord.attributes.spree/vendor") %> + + Onboarding progress + + Integration + + <%= sort_link @search, :products_count, Spree.t(:products_count) %> + + <%= sort_link @search, :sales_total, Spree.t(:sales_total) %> + + <%= sort_link @search, :commission_total, Spree.t(:commission_total) %> + + <%= sort_link @search, :created_at, Spree.t(:created_at) %> +
    +
    + <% else %> + <%= render "spree/admin/shared/no_resource_found", new_object_url: nil %> + <% end %> + <%= render partial: "spree/admin/shared/index_table_options", locals: { collection: @collection } if @vendors.any? %> +
    diff --git a/app/views/spree/admin/vendors/new.html.erb b/app/views/spree/admin/vendors/new.html.erb index d0cc461b..7d2e7a98 100644 --- a/app/views/spree/admin/vendors/new.html.erb +++ b/app/views/spree/admin/vendors/new.html.erb @@ -1,11 +1,50 @@ <% content_for :page_title do %> - <%= link_to plural_resource_name(Spree::Vendor), spree.admin_vendors_url %> / - <%= Spree.t(:new_vendor) %> + <%= page_header_back_button spree.admin_vendors_path %> + <%= Spree.t(:invite_vendor) %> <% end %> -<%= form_for [:admin, @vendor] do |f| %> -
    - <%= render partial: 'form', locals: { f: f } %> - <%= render partial: 'spree/admin/shared/new_resource_links' %> -
    +<%= turbo_frame_tag :dialog_modal do %> + <%= form_for [:admin, @vendor] do |f| %> + + <% end %> <% end %> diff --git a/app/views/spree/admin/vendors/show.html.erb b/app/views/spree/admin/vendors/show.html.erb new file mode 100644 index 00000000..1ae5e2ea --- /dev/null +++ b/app/views/spree/admin/vendors/show.html.erb @@ -0,0 +1,27 @@ +<% if turbo_frame_request? %> + <%= render 'spree/admin/vendors/display_commission_rate', vendor: @vendor if params[:display_commission_rate] %> + <%= render 'spree/admin/vendors/display_payouts_schedule', vendor: @vendor if params[:display_payouts_schedule] %> +<% else %> + <%= render 'header' %> + +
    +
    + <%= render 'spree/admin/vendors/card' %> + <%= render 'spree/admin/vendors/tabs', vendor: @vendor %> +
    + +
    + <%= render 'spree/admin/vendors/onboarding_status', vendor: @vendor %> + <%= render 'spree/admin/vendors/totals', vendor: @vendor %> + <%= render 'spree/admin/vendors/stripe' %> + <%= render 'spree/admin/vendors/returns_address' %> + <%= render 'spree/admin/vendors/billing' %> +
    +
    +<% end %> + + diff --git a/app/views/spree/admin/vendors/translations.html.erb b/app/views/spree/admin/vendors/translations.html.erb deleted file mode 100644 index ab8c9d03..00000000 --- a/app/views/spree/admin/vendors/translations.html.erb +++ /dev/null @@ -1,5 +0,0 @@ -<%= render partial: 'spree/admin/vendors/tabs', locals: { current: :translations } %> - -
    - <%= render 'spree/admin/translations/form', resource: @object %> -
    diff --git a/app/views/spree/admin/vendors/update.turbo_stream.erb b/app/views/spree/admin/vendors/update.turbo_stream.erb new file mode 100644 index 00000000..74dfcf26 --- /dev/null +++ b/app/views/spree/admin/vendors/update.turbo_stream.erb @@ -0,0 +1,23 @@ +<% @vendor.reload # reload associations %> + +<%= turbo_stream.replace "vendor_card_#{@vendor.id}" do %> + <%= render 'spree/admin/vendors/card', vendor: @vendor %> +<% end %> + +<%= turbo_stream.replace "vendor_commision_rate_#{@vendor.id}" do %> + <%= render 'spree/admin/vendors/display_commission_rate', vendor: @vendor %> +<% end %> + +<%= turbo_stream.replace "vendor_payouts_schedule_#{@vendor.id}" do %> + <%= render 'spree/admin/vendors/display_payouts_schedule', vendor: @vendor %> +<% end %> + +<%= turbo_stream.replace :onboarding_tasks do %> + <%= turbo_frame_tag :onboarding_tasks do %> + <%= render 'spree/admin/dashboard/vendor_onboarding_progress', vendor: @vendor %> + <%= render 'spree/admin/dashboard/vendor_onboarding_tasks', vendor: @vendor %> + <% end %> +<% end if current_vendor %> + +<%= turbo_render_alerts %> +<%= turbo_close_modal %> \ No newline at end of file diff --git a/app/views/spree/api/v1/vendors/index.rabl b/app/views/spree/api/v1/vendors/index.rabl deleted file mode 100644 index 3bf46a0c..00000000 --- a/app/views/spree/api/v1/vendors/index.rabl +++ /dev/null @@ -1,3 +0,0 @@ -collection @vendors - -extends 'spree/api/v1/vendors/show' diff --git a/app/views/spree/api/v1/vendors/show.rabl b/app/views/spree/api/v1/vendors/show.rabl deleted file mode 100644 index 770702da..00000000 --- a/app/views/spree/api/v1/vendors/show.rabl +++ /dev/null @@ -1,2 +0,0 @@ -object @vendor -attributes *vendor_attributes diff --git a/app/views/spree/order_mailer/splitted_order_confirm_email.html.erb b/app/views/spree/order_mailer/splitted_order_confirm_email.html.erb new file mode 100644 index 00000000..b4d2bf45 --- /dev/null +++ b/app/views/spree/order_mailer/splitted_order_confirm_email.html.erb @@ -0,0 +1,25 @@ +

    + <%= Spree.t('order_mailer.confirm_email.dear_customer', name: name_for(@order)) %> +

    +

    + <%= Spree.t('order_mailer.confirm_email.instructions') %> +

    +<% @order.vendor_orders.each do |order| %> + + + + + + + + +<% end %> +

    + <%= Spree.t('order_mailer.confirm_email.thanks') %> +
    + <%= Spree.t('order_mailer.store_team', store_name: current_store.name) %> +

    diff --git a/app/views/spree/order_mailer/vendor_order_confirm_email.html.erb b/app/views/spree/order_mailer/vendor_order_confirm_email.html.erb new file mode 100644 index 00000000..5ce54a7f --- /dev/null +++ b/app/views/spree/order_mailer/vendor_order_confirm_email.html.erb @@ -0,0 +1,36 @@ +

    + <%= @order.name %> placed order <%= @order.number %> on <%= @order.completed_at.in_time_zone(@order.store.preferred_timezone).strftime('%b%e ') %> at <%= @order.completed_at.in_time_zone(@order.store.preferred_timezone).strftime('%l:%M %P') %> +

    +

    + View order +

    + + + + + + + + + + + diff --git a/app/views/spree/page_sections/_brand_story.html.erb b/app/views/spree/page_sections/_brand_story.html.erb new file mode 100644 index 00000000..94de4e1b --- /dev/null +++ b/app/views/spree/page_sections/_brand_story.html.erb @@ -0,0 +1,51 @@ +<% cache_unless page_builder_enabled?, [*spree_base_cache_scope.call(section), product.brand].compact do %> + <% if product.brand && product.brand.image.attached? && product.brand.description.present? %> + <% logo = product.brand.square_image %> + +
    +
    + + +
    + <% if logo.attached? && logo.variable? %> + <%= link_to spree.nested_taxons_path(product.brand) do %> + <%= spree_image_tag(logo, width: 64, height: 64, class: 'rounded', loading: :lazy, alt: product.brand.name) %> + <% end %> + <% end %> +
    + <%= link_to spree.nested_taxons_path(product.brand) do %> + <%= spree_image_tag(product.brand.image, width: 360, height: 360, class: 'rounded', loading: :lazy, alt: product.brand.name) %> + <% end %> +
    +
    + <%= product.brand.description %> +
    + <%= link_to "#{Spree.t(:view)} #{product.brand.name}", spree.nested_taxons_path(product.brand), class: "link" %> +
    +
    +
    + <% end %> +<% end %> diff --git a/app/views/spree/vendor_mailer/vendor_approved.html.erb b/app/views/spree/vendor_mailer/vendor_approved.html.erb new file mode 100644 index 00000000..b616cf75 --- /dev/null +++ b/app/views/spree/vendor_mailer/vendor_approved.html.erb @@ -0,0 +1,25 @@ +<% if @vendor.users.include?(params[:recipient]) %> +

    + <%= Spree.t('vendor_mailer.greeting', vendor_name: @vendor.name) %> +

    +

    + <%= Spree.t('vendor_mailer.approved_email.message', vendor_name: @vendor.name, store_name: current_store.name).html_safe %> +

    +

    + <%= link_to 'Go to Dashboard', spree.admin_dashboard_url(host: @vendor.store.url) %> +

    +

    + <%= Spree.t('vendor_mailer.thanks') %> +
    + <%= current_store.name %> +

    +<% else %> +

    Hi <%= params[:recipient].first_name %>!

    +

    + <%= Spree.t('vendor_mailer.approved_email_to_marketplace_owners.message', vendor_name: @vendor.name, store_name: current_store.name).html_safe %> +

    +

    + <%= Spree.t('vendor_mailer.approved_email_to_marketplace_owners.link_to_profile_page', + profile_page: link_to('profile page', spree.admin_vendor_url(@vendor, host: current_store.url))).html_safe %> +

    +<% end %> diff --git a/app/views/spree/vendor_mailer/vendor_approved.text.erb b/app/views/spree/vendor_mailer/vendor_approved.text.erb new file mode 100644 index 00000000..8463f110 --- /dev/null +++ b/app/views/spree/vendor_mailer/vendor_approved.text.erb @@ -0,0 +1,8 @@ +<%= Spree.t('vendor_mailer.greeting', vendor_name: @vendor.name) %> + +<%= strip_tags Spree.t('vendor_mailer.approved_email.message', vendor_name: @vendor.name, store_name: current_store.name) %> +Go to the dashboard by clicking on: <%= spree.admin_dashboard_url(host: @vendor.store.url) %> + +============================================================ +<%= Spree.t('vendor_mailer.thanks') %> +<%= current_store.name %> diff --git a/app/views/spree/vendor_mailer/vendor_notification_email.html.erb b/app/views/spree/vendor_mailer/vendor_notification_email.html.erb deleted file mode 100644 index 8c7fcdfb..00000000 --- a/app/views/spree/vendor_mailer/vendor_notification_email.html.erb +++ /dev/null @@ -1,81 +0,0 @@ - - - - - -
    -

    - <%= Spree.t('order_mailer.vendor_notification_email.dear_vendor') %> -

    -

    - <%= Spree.t('order_mailer.vendor_notification_email.order_summary', number: @order.number) %> -

    - - - - -
    - - <%= render collection: @line_items, partial: 'spree/shared/mailer_line_item', as: :line_item %> - - - - - <% if @order.line_item_adjustments.exists? %> - <% if @order.all_adjustments.promotion.eligible.exists? %> - <% @order.all_adjustments.promotion.eligible.group_by(&:label).each do |label, adjustments| %> - - - - - <% end %> - <% end %> - <% end %> - <% @order.shipments.for_vendor(@vendor).group_by { |s| s.selected_shipping_rate.try(:name) }.each do |name, shipments| %> - - - - - <% end %> - <% if @order.all_adjustments.eligible.tax.exists? %> - <% @order.all_adjustments.eligible.tax.group_by(&:label).each do |label, adjustments| %> - - - - - <% end %> - <% end %> - <% @order.adjustments.eligible.each do |adjustment| %> - <% next if (adjustment.source_type == 'Spree::TaxRate') || (adjustment.amount == 0) %> - <%= render 'spree/order_mailer/adjustment', adjustment: adjustment %> - <% end %> - - - - -
    - <%= Spree.t('order_mailer.subtotal') %> - - <%= Spree::Money.new(@subtotal, currency: @order.currency) %> -
    - <%= Spree.t(:promotion) %> <%= label %>: - - <%= Spree::Money.new(adjustments.sum(&:amount), currency: @order.currency) %> -
    - <%= Spree.t(:shipping) %> <%= name %>: - - <%= Spree::Money.new(shipments.sum(&:discounted_cost), currency: @order.currency) %> -
    - <%= Spree.t(:tax) %> <%= label %>: - - <%= Spree::Money.new(adjustments.sum(&:amount), currency: @order.currency) %> -
    - <%= Spree.t('order_mailer.total') %> - - <%= Spree::Money.new(@total, currency: @order.currency) %> -
    -
    -

    - <%= Spree.t('order_mailer.vendor_notification_email.thanks') %> -

    -
    diff --git a/app/views/spree/vendor_mailer/vendor_notification_email.text.erb b/app/views/spree/vendor_mailer/vendor_notification_email.text.erb deleted file mode 100644 index e9f15702..00000000 --- a/app/views/spree/vendor_mailer/vendor_notification_email.text.erb +++ /dev/null @@ -1,36 +0,0 @@ -<%= Spree.t('order_mailer.vendor_notification_email.dear_vendor') %> - -============================================================ -<%= Spree.t('order_mailer.vendor_notification_email.order_summary', number: @order.number) %> -============================================================ -<% @line_items.each do |item| %> - <%= item.variant.sku %> <%= raw(item.variant.product.name) %> <%= raw(item.variant.options_text) -%> (<%=item.quantity%>) <%= Spree.t('at_symbol') %> <%= item.single_money %> = <%= item.display_amount %> -<% end %> -============================================================ -<%= Spree.t('order_mailer.subtotal') %> <%= Spree::Money.new(@subtotal, currency: @order.currency) %> -<% if @order.line_item_adjustments.exists? %> - <% if @order.all_adjustments.promotion.eligible.exists? %> - <% @order.all_adjustments.promotion.eligible.group_by(&:label).each do |label, adjustments| %> - <%= Spree.t(:promotion) %>: <%= label %> <%= Spree::Money.new(adjustments.sum(&:amount), currency: @order.currency) %> - <% end %> - <% end %> -<% end %> - -<% @order.shipments.group_by { |s| s.selected_shipping_rate.try(:name) }.each do |name, shipments| %> - <%= Spree.t(:shipping) %>: <%= name %> <%= Spree::Money.new(shipments.sum(&:discounted_cost), currency: @order.currency) %> -<% end %> - -<% if @order.all_adjustments.eligible.tax.exists? %> - <% @order.all_adjustments.eligible.tax.group_by(&:label).each do |label, adjustments| %> - <%= Spree.t(:tax) %>: <%= label %> <%= Spree::Money.new(adjustments.sum(&:amount), currency: @order.currency) %> - <% end %> -<% end %> - -<% @order.adjustments.eligible.each do |adjustment| %> - <% next if (adjustment.source_type == 'Spree::TaxRate') and (adjustment.amount == 0) %> - <%= adjustment.label %> <%= adjustment.display_amount %> -<% end %> -============================================================ -<%= Spree.t('order_mailer.total') %> <%= Spree::Money.new(@total, currency: @order.currency) %> - -<%= Spree.t('order_mailer.vendor_notification_email.thanks') %> diff --git a/app/views/spree/vendor_mailer/vendor_onboarding_completed.html.erb b/app/views/spree/vendor_mailer/vendor_onboarding_completed.html.erb new file mode 100644 index 00000000..41819904 --- /dev/null +++ b/app/views/spree/vendor_mailer/vendor_onboarding_completed.html.erb @@ -0,0 +1,6 @@ +

    Hi <%= params[:recipient].first_name %>!

    +

    + <%= Spree.t('vendor_mailer.onboarding_completed.message', + vendor_name: @vendor.name, + profile_page: link_to('profile page', spree.admin_vendor_url(@vendor, host: current_store.url))).html_safe %> +

    diff --git a/app/views/spree/vendor_mailer/vendor_onboarding_completed.text.erb b/app/views/spree/vendor_mailer/vendor_onboarding_completed.text.erb new file mode 100644 index 00000000..0332b912 --- /dev/null +++ b/app/views/spree/vendor_mailer/vendor_onboarding_completed.text.erb @@ -0,0 +1,3 @@ +<%= strip_tags Spree.t('vendor_mailer.onboarding_completed.message', + vendor_name: @vendor.name, + profile_page: "profile page: #{spree.admin_vendor_url(@vendor, host: current_store.url)}") %> diff --git a/app/views/spree/vendor_mailer/vendor_onboarding_started.html.erb b/app/views/spree/vendor_mailer/vendor_onboarding_started.html.erb new file mode 100644 index 00000000..6c074b0e --- /dev/null +++ b/app/views/spree/vendor_mailer/vendor_onboarding_started.html.erb @@ -0,0 +1,6 @@ +

    Hi <%= params[:recipient].first_name %>!

    +

    + <%= Spree.t('vendor_mailer.onboarding_started.message', + vendor_name: @vendor.name, + profile_page: link_to('profile page', spree.admin_vendor_url(@vendor, host: current_store.url))).html_safe %> +

    diff --git a/app/views/spree/vendor_mailer/vendor_onboarding_started.text.erb b/app/views/spree/vendor_mailer/vendor_onboarding_started.text.erb new file mode 100644 index 00000000..bc217617 --- /dev/null +++ b/app/views/spree/vendor_mailer/vendor_onboarding_started.text.erb @@ -0,0 +1,3 @@ +<%= strip_tags Spree.t('vendor_mailer.onboarding_started.message', + vendor_name: @vendor.name, + profile_page: "profile page: #{spree.admin_vendor_url(@vendor, host: current_store.url)}") %> diff --git a/app/views/spree/vendor_mailer/vendor_rejected.html.erb b/app/views/spree/vendor_mailer/vendor_rejected.html.erb new file mode 100644 index 00000000..50f93084 --- /dev/null +++ b/app/views/spree/vendor_mailer/vendor_rejected.html.erb @@ -0,0 +1,11 @@ +

    + <%= Spree.t('vendor_mailer.greeting', vendor_name: @vendor.name) %> +

    +

    + <%= Spree.t('vendor_mailer.rejected_email.message', store_email: current_store.mail_from_address) %> +

    +

    + <%= Spree.t('vendor_mailer.thanks') %> +
    + <%= current_store.name %> +

    diff --git a/app/views/spree/vendor_mailer/vendor_rejected.text.erb b/app/views/spree/vendor_mailer/vendor_rejected.text.erb new file mode 100644 index 00000000..1f902eec --- /dev/null +++ b/app/views/spree/vendor_mailer/vendor_rejected.text.erb @@ -0,0 +1,7 @@ +<%= Spree.t('vendor_mailer.greeting', vendor_name: @vendor.name) %> + +<%= Spree.t('vendor_mailer.rejected_email.message', store_email: current_store.mail_from_address) %> + +============================================================ +<%= Spree.t('vendor_mailer.thanks') %> +<%= current_store.name %> diff --git a/app/views/spree/vendor_mailer/vendor_suspended.html.erb b/app/views/spree/vendor_mailer/vendor_suspended.html.erb new file mode 100644 index 00000000..8c593cd5 --- /dev/null +++ b/app/views/spree/vendor_mailer/vendor_suspended.html.erb @@ -0,0 +1,11 @@ +

    + <%= Spree.t('vendor_mailer.greeting', vendor_name: @vendor.name) %> +

    +

    + <%= Spree.t('vendor_mailer.suspended_email.message', store_email: current_store.mail_from_address) %> +

    +

    + <%= Spree.t('vendor_mailer.thanks') %> +
    + <%= current_store.name %> +

    diff --git a/app/views/spree/vendor_mailer/vendor_suspended.text.erb b/app/views/spree/vendor_mailer/vendor_suspended.text.erb new file mode 100644 index 00000000..64532e6a --- /dev/null +++ b/app/views/spree/vendor_mailer/vendor_suspended.text.erb @@ -0,0 +1,7 @@ +<%= Spree.t('vendor_mailer.greeting', vendor_name: @vendor.name) %> + +<%= Spree.t('vendor_mailer.suspended_email.message', store_email: current_store.mail_from_address) %> + +============================================================ +<%= Spree.t('vendor_mailer.thanks') %> +<%= current_store.name %> diff --git a/app/views/spree_multi_vendor/_head.html.erb b/app/views/spree_multi_vendor/_head.html.erb new file mode 100644 index 00000000..c49bd250 --- /dev/null +++ b/app/views/spree_multi_vendor/_head.html.erb @@ -0,0 +1 @@ +<%= javascript_import_module_tag 'application-spree-multi-vendor' %> \ No newline at end of file diff --git a/bin/importmap b/bin/importmap new file mode 100755 index 00000000..bf720be9 --- /dev/null +++ b/bin/importmap @@ -0,0 +1,9 @@ +#!/usr/bin/env ruby + +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) +require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) + +require 'rails' + +require 'importmap-rails' +require 'importmap/commands' \ No newline at end of file diff --git a/bin/rails b/bin/rails old mode 100644 new mode 100755 index 2762b3ff..5806d5a5 --- a/bin/rails +++ b/bin/rails @@ -1,4 +1,5 @@ -# This command will automatically be run when you run "rails" with Rails 3 gems installed from the root of your application. +#!/usr/bin/env ruby +# This command will automatically be run when you run "rails" from the root of your extension ENGINE_ROOT = File.expand_path('../..', __FILE__) ENGINE_PATH = File.expand_path('../../lib/spree_multi_vendor/engine', __FILE__) diff --git a/config/importmap.rb b/config/importmap.rb new file mode 100644 index 00000000..10f12833 --- /dev/null +++ b/config/importmap.rb @@ -0,0 +1,6 @@ +pin 'application-spree-multi-vendor', to: 'spree_multi_vendor/application.js', preload: false + +pin_all_from SpreeMultiVendor::Engine.root.join('app/javascript/spree_multi_vendor/controllers'), + under: 'spree_multi_vendor/controllers', + to: 'spree_multi_vendor/controllers', + preload: 'application-spree-multi-vendor' diff --git a/config/initializers/admin_panel.rb b/config/initializers/admin_panel.rb deleted file mode 100644 index e98ac664..00000000 --- a/config/initializers/admin_panel.rb +++ /dev/null @@ -1,7 +0,0 @@ -Rails.application.config.after_initialize do - if Spree::Core::Engine.backend_available? - Rails.application.config.spree_backend.main_menu.insert_before('users', ::SpreeMultiVendor::Admin::MainMenu::OrganizationBuilder.new.build) - - Rails.application.config.spree_backend.tabs[:vendor] = ::SpreeMultiVendor::Admin::Tabs::VendorTabsBuilder.new.build - end -end diff --git a/config/initializers/spree.rb b/config/initializers/spree.rb new file mode 100644 index 00000000..a19df280 --- /dev/null +++ b/config/initializers/spree.rb @@ -0,0 +1,191 @@ +Rails.application.config.after_initialize do + Spree.admin.partials.dashboard_sidebar << 'spree/admin/dashboard/vendors' + Spree.admin.partials.product_form_sidebar << 'spree/admin/products/form/platform_fee' + + # Filters + Spree.admin.partials.orders_filters << 'spree/admin/orders/vendor_filter' + Spree.admin.partials.products_filters << 'spree/admin/products/vendor_filter' + + # Order Page + Spree.admin.partials.order_page_body << 'spree/admin/orders/marketplace_suborders' + Spree.admin.partials.order_page_summary << 'spree/admin/orders/marketplace_summary' + + # Shipping methods + Spree.admin.partials.shipping_methods_actions << 'spree/admin/shipping_methods/actions' + + # Metafields + Spree.metafields.enabled_resources << Spree::Vendor + + # Translations + Spree.translatable_resources << Spree::Vendor + + # =============================================== + # Navigation for marketplace owners + # =============================================== + + # Vendors + Spree.admin.navigation.sidebar.add :vendors, + label: :vendors, + url: :admin_vendors_path, + icon: 'heart-handshake', + position: 35, + active: -> { %w[vendors].include?(controller_name) }, + if: -> { can?(:manage, current_store) } do |vendors| + # Vendors to review + vendors.add :vendors_to_review, + label: :vendors_to_review, + url: -> { spree.admin_vendors_path(q: { state_eq: 'ready_for_review' }) }, + position: 10, + badge: -> { ready_for_review_vendors_count }, + active: -> { %w[vendors].include?(controller_name) && params.dig(:q, :state_eq) == 'ready_for_review' }, + if: -> { can?(:manage, current_store) && ready_for_review_vendors_count&.positive? } + end + + # Marketplace settings + Spree.admin.navigation.settings.add :marketplace, + label: :marketplace, + url: :edit_admin_marketplace_settings_path, + icon: 'heart-handshake', + position: 51, + active: -> { %w[marketplace_settings].include?(controller_name) }, + if: -> { can?(:manage, current_store) } + + # =============================================== + # Navigation for vendors + # =============================================== + vendor_nav = Spree.admin.navigation.register_context(:vendor_sidebar) + + # Dashboard / Getting Started + vendor_nav.add :getting_started, + label: 'admin.getting_started', + url: :admin_getting_started_path, + icon: 'map', + position: 5, + active: -> { controller_name == 'dashboard' && action_name == 'getting_started' }, + if: -> { !current_vendor.onboarding_completed? }, + badge: -> { "#{current_vendor.onboarding_tasks_done}/#{current_vendor.onboarding_tasks_total}" }, + badge_class: 'badge-info' + + # Dashboard / Home + vendor_nav.add :home, + label: :home, + url: :admin_path, + icon: 'home', + position: 10, + active: -> { controller_name == 'dashboard' && action_name == 'show' } + + # Orders with submenu + vendor_nav.add :orders, + label: :orders, + url: :admin_orders_path, + icon: 'inbox', + position: 20, + if: -> { can?(:manage, Spree::Order) }, + badge: -> { ready_to_ship_orders_count if ready_to_ship_orders_count&.positive? } do |orders| + # Orders to Fulfill submenu + orders.add :orders_to_fulfill, + label: 'admin.orders.orders_to_fulfill', + url: -> { spree.admin_orders_path(q: {shipment_state_not_in: [:shipped, :canceled]}) }, + position: 10, + active: -> { controller_name == 'orders' && params.dig(:q, :shipment_state_not_in).present? }, + if: -> { ready_to_ship_orders_count&.positive? }, + badge: -> { ready_to_ship_orders_count if ready_to_ship_orders_count&.positive? } + end + + # Products with submenu + vendor_nav.add :products, + label: :products, + url: :admin_products_path, + icon: 'package', + position: 30, + if: -> { can?(:manage, Spree::Product) } do |products| + # Stock + products.add :stock, + label: :stock, + url: :admin_stock_items_path, + position: 10, + active: -> { %w[stock_items stock_transfers].include?(controller_name) }, + if: -> { can?(:manage, Spree::StockItem) || can?(:manage, Spree::StockTransfer) } + end + + # Section divider before settings + vendor_nav.add :settings_section, + section_label: 'Settings', + position: 90 + + # Vendor settings (bottom of sidebar) + vendor_nav.add :settings, + label: :settings, + url: :edit_admin_vendor_settings_path, + icon: 'settings', + position: 100, + if: -> { can?(:manage, current_vendor) } + + # Admin Users (bottom of sidebar) + vendor_nav.add :admin_users, + label: :users, + url: :admin_admin_users_path, + icon: 'users', + position: 110, + if: -> { can?(:manage, current_vendor) } + + # =============================================== + # Vendor settings navigation + # =============================================== + vendor_settings_nav = Spree.admin.navigation.register_context(:vendor_settings) + + # Vendor settings (bottom of sidebar) + vendor_settings_nav.add :settings, + label: :settings, + url: :edit_admin_vendor_settings_path, + icon: 'settings', + position: 10, + if: -> { can?(:manage, current_vendor) }, + active: -> { %w[vendor_settings].include?(controller_name) } + + # Admin Users + vendor_settings_nav.add :admin_users, + label: :users, + url: :admin_admin_users_path, + icon: 'users', + position: 20, + if: -> { can?(:manage, current_vendor) }, + active: -> { %w[admin_users invitations].include?(controller_name) } + + # Shipping methods + vendor_settings_nav.add :shipping_methods, + label: :shipping, + url: :admin_shipping_methods_path, + icon: 'truck', + position: 30, + if: -> { can?(:manage, Spree::ShippingMethod) }, + active: -> { %w[shipping_methods].include?(controller_name) } + + # Stock locations + vendor_settings_nav.add :stock_locations, + label: :stock_locations, + url: :admin_stock_locations_path, + icon: 'map-pin', + position: 40, + if: -> { can?(:manage, Spree::StockLocation) }, + active: -> { %w[stock_locations].include?(controller_name) } + + # Policies + vendor_settings_nav.add :policies, + label: :policies, + url: :admin_policies_path, + icon: 'list-check', + position: 50, + if: -> { can?(:manage, Spree::Policy) }, + active: -> { %w[policies].include?(controller_name) } +end + +module Spree + module PermittedAttributes + @@product_attributes += %i[platform_fee] + @@store_attributes += %i[ + preferred_platform_fee + preferred_vendor_payouts_schedule_interval + ] + end +end diff --git a/config/initializers/spree_permitted_attributes.rb b/config/initializers/spree_permitted_attributes.rb deleted file mode 100644 index 283a78e5..00000000 --- a/config/initializers/spree_permitted_attributes.rb +++ /dev/null @@ -1,10 +0,0 @@ -module Spree - module PermittedAttributes - ATTRIBUTES << :vendor_attributes - - mattr_reader *ATTRIBUTES - - @@vendor_attributes = [:name, :about_us, :contact_us, :notification_email] - @@vendor_attributes << :image - end -end diff --git a/config/locales/en.yml b/config/locales/en.yml index ea1f71a5..ff096278 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,35 +1,80 @@ +--- en: activerecord: - models: - spree/vendor: - one: Vendor - other: Vendors attributes: spree/vendor: - about_us: About us - contact_us: Contact us - image: Image - name: name - state: state - created_at: created at - priority: priority + name: Name + state: Status + errors: + models: + spree/vendor: + attributes: + base: + cannot_destroy_vendor: Can't delete a partner with unfulfilled orders + contact_person_email: + cannot_be_store_admin: cannot belong to a store admin spree: - back_to_vendors_list: Back To Vendors List - blocked: Blocked - edit_vendor: Edit Vendor - new_vendor: New Vendor - only_alphanumeric_chars: should contain only letters and digits - order_mailer: - vendor_notification_email: - dear_vendor: Dear Vendor, - subject: Vendor notification - thanks: Thanks - vendor: Vendor - vendor_name: Vendor Name - vendor_states: - active: Active - blocked: Blocked - pending: Pending admin: - commission: Commission - vendors: Vendors + marketplace: Marketplace + vendors: + all: All Vendors + approved: Approved + invited: Invited + onboarding: Onboarding + pending: Pending + ready_for_review: Ready for Review + rejected: Rejected + suspended: Suspended + invite_vendor: Invite Vendor + payouts: Payouts + suborders: Suborders + transactions: Transactions + vendor_delivery_reimbursement_total: Vendor Shipping Reimbursement Total + vendor_form: + billing: Billing + billing_address: Billing Address + billing_email: Billing Email + contact_information: Contact Information + contact_person_email: Contact person email + name: Company or brand name + platform_fee: Commission rate + public_information: Public Information + settings: Settings + shopify_shop_name: Shopify Shop Name + vendor_mailer: + account: Account + approved_email: + message: 'Congrats %{vendor_name}, you have been accepted + to %{store_name}!
    You can now log in to your supplier dashboard:' + subject: "%{vendor_name} has been accepted to sell on %{store_name}" + approved_email_to_marketplace_owners: + link_to_profile_page: You can check their %{profile_page} + message: "%{vendor_name} has been approved to start selling on %{store_name}!" + greeting: Hey %{vendor_name}, + onboarding_completed: + message: Good news, %{vendor_name} completed their onboarding process.

    + You can review their %{profile_page} and decide to approve %{vendor_name} + so they can start selling on your platform. + subject: "%{vendor_name} completed their onboarding process!" + onboarding_started: + message: Good news, %{vendor_name} accepted your invitation and started the + onboarding process.

    You can track their progress on their %{profile_page} + subject: "%{vendor_name} just started the onboarding process!" + rejected_email: + message: Unfortunately we had to reject your application. If you would like + to discuss this matter please contact us via email at %{store_email} + subject: "%{vendor_name} has been rejected by %{store_name}" + suspended_email: + message: We're sorry but your organization was suspended. If you would like + to discuss this matter please contact us via email %{store_email} + subject: "%{vendor_name} has been suspended by %{store_name}" + thanks: Thank you, + vendor_onboarding_tasks: + add_billing_address: Add billing address + add_policies: Add policies + add_returns_address: Add returns address + map_shipping_methods: Map shipping methods + set_shipping_rates: Set shipping methods & rates + vendor_payouts_schedule_interval: Payouts schedule interval + vendor_reimbursement: Vendor Reimbursement + vendor_terms_of_service: Vendor Terms of Service diff --git a/config/locales/es.yml b/config/locales/es.yml deleted file mode 100644 index d7b30a5b..00000000 --- a/config/locales/es.yml +++ /dev/null @@ -1,37 +0,0 @@ -es: - activerecord: - models: - spree/vendor: - one: Vendedor - other: Vendedores - attributes: - spree/vendor: - about_us: Acerca de - commission_rate: Porcentaje Comisión - contact_us: Contáctanos - image: Imagen - name: Nombre - notification_email: Email notificación pedido - state: estado - created_at: creado en - priority: prioridad - spree: - back_to_vendors_list: Volver a la lista de vendedores - edit_vendor: Editar vendedor - new_vendor: Nuevo vendedor - only_alphanumeric_chars: Debería contener solo letras y dígitos - order_mailer: - vendor_notification_email: - dear_vendor: Estimado vendedor, - order_summary: Resumen del pedido - subject: Notificación de vendedor - thanks: Gracias - vendor: Vendedor - vendor_name: Nombre del vendedor - vendor_states: - active: Activo - blocked: Bloqueado - pending: Pendiente - admin: - commission: Comisión - vendors: Vendedores \ No newline at end of file diff --git a/config/locales/fr.yml b/config/locales/fr.yml deleted file mode 100644 index 4beed8db..00000000 --- a/config/locales/fr.yml +++ /dev/null @@ -1,34 +0,0 @@ -fr: - activerecord: - models: - spree/vendor: - one: Vendeur - other: Vendeurs - attributes: - spree/vendor: - about_us: A propos - contact_us: Contact - image: Image - name: Nom - state: Status - created_at: créé le - priority: priorité - spree: - back_to_vendors_list: Retour à la liste des vendeurs - edit_vendor: Modifier vendeur - new_vendor: Nouveau vendeur - only_alphanumeric_chars: ne doit contenir que des caractères alphanumériques - order_mailer: - vendor_notification_email: - dear_vendor: Cher vendeur, - subject: Notification vendeur - thanks: Merci - vendor: Vendeur - vendor_name: Nom du vendeur - vendor_states: - active: Actif - blocked: Bloqué - pending: En attente - admin: - commission: Commission - vendors: Vendeurs \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index e55b1bef..5926a23f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,27 +1,53 @@ -Spree::Core::Engine.routes.draw do - namespace :admin do +Spree::Core::Engine.add_routes do + # admin panel routes + namespace :admin, path: Spree.admin_path do resources :vendors do + collection do + get :select_options, defaults: { format: :json } + end member do - get :translations - post :translations, to: 'vendors#edit_translations' + get :edit_commission_rate + get :edit_payouts_schedule + get :edit_returns_address + get :edit_billing_address + get :edit_returns_policy + get :edit_brand + patch :approve + patch :reject + patch :suspend + patch :archive + patch :confirm_shipping_rates + patch :use_no_external_store end + resources :products, only: %i[index] + resources :orders, only: [:index] - collection do - post :update_positions - end + resources :role_users, only: [:destroy] end - get 'vendor_settings' => 'vendor_settings#edit' - patch 'vendor_settings' => 'vendor_settings#update' + resource :vendor_settings, only: [:edit, :update], controller: 'vendor_settings' + + resource :marketplace_settings, only: [:edit, :update], controller: 'marketplace_settings' end + # api routes namespace :api, defaults: { format: 'json' } do namespace :v2 do + namespace :platform do + resources :vendors do + member do + patch :invite + patch :start_onboarding + patch :complete_onboarding + patch :approve + patch :reject + patch :suspend + end + end + end + namespace :storefront do - resources :vendors, only: [:show,:index] + resources :vendors, only: [:show, :index] end end - namespace :v1 do - resources :vendors - end end end diff --git a/db/default/vendors.rb b/db/default/vendors.rb deleted file mode 100644 index d18cb515..00000000 --- a/db/default/vendors.rb +++ /dev/null @@ -1,13 +0,0 @@ -vendor = Spree::Vendor.first_or_initialize do |v| - v.name = 'Test Vendor' -end -vendor.save! - -puts "Created Vendor with the name \"#{vendor.name}\"!" - -user = Spree.user_class.where(email: 'user@vendor.com').first_or_initialize do |u| - u.password = u.password_confirmation = 'vendor123' -end - -vendor.users << user unless vendor.users.include?(user) -puts "Created Vendor Admin User with an email \"#{user.email}\" and password \"#{user.password}\"!" \ No newline at end of file diff --git a/db/migrate/20170406102250_create_spree_vendors.rb b/db/migrate/20170406102250_create_spree_vendors.rb deleted file mode 100644 index 4f347c5f..00000000 --- a/db/migrate/20170406102250_create_spree_vendors.rb +++ /dev/null @@ -1,9 +0,0 @@ -class CreateSpreeVendors < SpreeExtension::Migration[4.2] - def change - create_table :spree_vendors do |t| - t.string :name - t.timestamps - end - add_index :spree_vendors, :name, unique: true - end -end diff --git a/db/migrate/20170406102944_add_vendor_id_to_spree_models.rb b/db/migrate/20170406102944_add_vendor_id_to_spree_models.rb deleted file mode 100644 index f3fa3185..00000000 --- a/db/migrate/20170406102944_add_vendor_id_to_spree_models.rb +++ /dev/null @@ -1,10 +0,0 @@ -class AddVendorIdToSpreeModels < SpreeExtension::Migration[4.2] - def change - SpreeMultiVendor.vendorized_models.each do |klass| - add_reference klass.table_name, :vendor, index: true - rescue - message = "Could not add vendor_id column to spree_#{klass}" - Rails.logger.error message - end - end -end diff --git a/db/migrate/20170410111150_add_state_to_vendors.rb b/db/migrate/20170410111150_add_state_to_vendors.rb deleted file mode 100644 index 04cc5d48..00000000 --- a/db/migrate/20170410111150_add_state_to_vendors.rb +++ /dev/null @@ -1,6 +0,0 @@ -class AddStateToVendors < SpreeExtension::Migration[4.2] - def change - add_column :spree_vendors, :state, :string - add_index :spree_vendors, :state - end -end diff --git a/db/migrate/20170412124925_add_deleted_at_to_spree_vendors.rb b/db/migrate/20170412124925_add_deleted_at_to_spree_vendors.rb deleted file mode 100644 index 978b4852..00000000 --- a/db/migrate/20170412124925_add_deleted_at_to_spree_vendors.rb +++ /dev/null @@ -1,6 +0,0 @@ -class AddDeletedAtToSpreeVendors < SpreeExtension::Migration[4.2] - def change - add_column :spree_vendors, :deleted_at, :datetime - add_index :spree_vendors, :deleted_at - end -end diff --git a/db/migrate/20170413094447_create_spree_vendor_users.rb b/db/migrate/20170413094447_create_spree_vendor_users.rb deleted file mode 100644 index a00514c8..00000000 --- a/db/migrate/20170413094447_create_spree_vendor_users.rb +++ /dev/null @@ -1,11 +0,0 @@ -class CreateSpreeVendorUsers < SpreeExtension::Migration[4.2] - def change - create_table :spree_vendor_users do |t| - t.references :vendor - t.references :user - end - add_index :spree_vendor_users, [:vendor_id, :user_id], unique: true - add_index :spree_vendor_users, :vendor_id - add_index :spree_vendor_users, :user_id - end -end diff --git a/db/migrate/20190212161426_add_slug_to_spree_vendors.rb b/db/migrate/20190212161426_add_slug_to_spree_vendors.rb deleted file mode 100644 index a3403cc2..00000000 --- a/db/migrate/20190212161426_add_slug_to_spree_vendors.rb +++ /dev/null @@ -1,6 +0,0 @@ -class AddSlugToSpreeVendors < SpreeExtension::Migration[4.2] - def change - add_column :spree_vendors, :slug, :string - add_index :spree_vendors, :slug, unique: true - end -end diff --git a/db/migrate/20190214120226_add_about_us_to_spree_vendors.rb b/db/migrate/20190214120226_add_about_us_to_spree_vendors.rb deleted file mode 100644 index a3bd77fa..00000000 --- a/db/migrate/20190214120226_add_about_us_to_spree_vendors.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddAboutUsToSpreeVendors < SpreeExtension::Migration[4.2] - def change - add_column :spree_vendors, :about_us, :text - end -end diff --git a/db/migrate/20190214142526_add_contact_us_to_spree_vendors.rb b/db/migrate/20190214142526_add_contact_us_to_spree_vendors.rb deleted file mode 100644 index 3b9f79e3..00000000 --- a/db/migrate/20190214142526_add_contact_us_to_spree_vendors.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddContactUsToSpreeVendors < SpreeExtension::Migration[4.2] - def change - add_column :spree_vendors, :contact_us, :text - end -end diff --git a/db/migrate/20190305120337_add_commission_to_spree_vendors.rb b/db/migrate/20190305120337_add_commission_to_spree_vendors.rb deleted file mode 100644 index 672ec426..00000000 --- a/db/migrate/20190305120337_add_commission_to_spree_vendors.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddCommissionToSpreeVendors < SpreeExtension::Migration[4.2] - def change - add_column :spree_vendors, :commission_rate, :float, default: '5.0' - end -end diff --git a/db/migrate/20190308091546_add_priority_to_vendor.rb b/db/migrate/20190308091546_add_priority_to_vendor.rb deleted file mode 100644 index d10945ea..00000000 --- a/db/migrate/20190308091546_add_priority_to_vendor.rb +++ /dev/null @@ -1,8 +0,0 @@ -class AddPriorityToVendor < SpreeExtension::Migration[4.2] - def change - add_column :spree_vendors, :priority, :integer - Spree::Vendor.order(:updated_at).each.with_index(1) do |vendor, index| - vendor.update_column :priority, index - end - end -end diff --git a/db/migrate/20190312130754_create_spree_order_commissions.rb b/db/migrate/20190312130754_create_spree_order_commissions.rb deleted file mode 100644 index 769a4048..00000000 --- a/db/migrate/20190312130754_create_spree_order_commissions.rb +++ /dev/null @@ -1,14 +0,0 @@ -class CreateSpreeOrderCommissions < SpreeExtension::Migration[4.2] - def change - create_table :spree_order_commissions do |t| - t.references :order - t.references :vendor - t.float :amount - - t.timestamps - end - add_index :spree_order_commissions, [:order_id, :vendor_id], unique: true - add_index :spree_order_commissions, :order_id - add_index :spree_order_commissions, :vendor_id - end -end diff --git a/db/migrate/20190322133128_add_notification_email_to_vendors.rb b/db/migrate/20190322133128_add_notification_email_to_vendors.rb deleted file mode 100644 index 3ba31f13..00000000 --- a/db/migrate/20190322133128_add_notification_email_to_vendors.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddNotificationEmailToVendors < SpreeExtension::Migration[4.2] - def change - add_column :spree_vendors, :notification_email, :string - end -end diff --git a/db/migrate/20200416030721_add_translations_to_vendor.rb b/db/migrate/20200416030721_add_translations_to_vendor.rb deleted file mode 100644 index 90b48e18..00000000 --- a/db/migrate/20200416030721_add_translations_to_vendor.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -# Based on issue https://github.com/spree-contrib/spree_multi_vendor/issues/104 -# This is a patch for Spree globalize to be friendly with the gem - -class AddTranslationsToVendor < ActiveRecord::Migration[4.2] - def up - params = { name: :string, about_us: :text, contact_us: :text, slug: :string } - if defined?(SpreeGlobalize) - Spree::Vendor.create_translation_table!(params, { migrate_data: true }) - end - end - - def down - if defined?(SpreeGlobalize) - Spree::Vendor.drop_translation_table! migrate_data: true - end - end -end diff --git a/db/migrate/20210728120649_change_vendorized_models_vendor_id_type.rb b/db/migrate/20210728120649_change_vendorized_models_vendor_id_type.rb deleted file mode 100644 index 628b282b..00000000 --- a/db/migrate/20210728120649_change_vendorized_models_vendor_id_type.rb +++ /dev/null @@ -1,10 +0,0 @@ -class ChangeVendorizedModelsVendorIdType < ActiveRecord::Migration[4.2] - def change - SpreeMultiVendor.vendorized_models.each do |klass| - change_column klass.table_name, :vendor_id, :bigint - rescue - message = "Could not change vendor_id column for spree_#{klass}" - Rails.logger.error message - end - end -end diff --git a/db/migrate/20210728120910_change_vendor_id_and_user_id_type_for_spree_vendor_users.rb b/db/migrate/20210728120910_change_vendor_id_and_user_id_type_for_spree_vendor_users.rb deleted file mode 100644 index 651f6180..00000000 --- a/db/migrate/20210728120910_change_vendor_id_and_user_id_type_for_spree_vendor_users.rb +++ /dev/null @@ -1,14 +0,0 @@ -class ChangeVendorIdAndUserIdTypeForSpreeVendorUsers < ActiveRecord::Migration[4.2] - def up - change_table(:spree_vendor_users) do |t| - t.change :vendor_id, :bigint - t.change :user_id, :bigint - end - end - def down - change_table(:spree_vendor_users) do |t| - t.change :vendor_id, :integer - t.change :user_id, :integer - end - end -end diff --git a/db/migrate/20210728121024_change_vendor_id_and_order_id_type_for_spree_order_commissions.rb b/db/migrate/20210728121024_change_vendor_id_and_order_id_type_for_spree_order_commissions.rb deleted file mode 100644 index 35330bd8..00000000 --- a/db/migrate/20210728121024_change_vendor_id_and_order_id_type_for_spree_order_commissions.rb +++ /dev/null @@ -1,14 +0,0 @@ -class ChangeVendorIdAndOrderIdTypeForSpreeOrderCommissions < ActiveRecord::Migration[4.2] - def up - change_table(:spree_order_commissions) do |t| - t.change :vendor_id, :bigint - t.change :order_id, :bigint - end - end - def down - change_table(:spree_order_commissions) do |t| - t.change :vendor_id, :integer - t.change :order_id, :integer - end - end -end diff --git a/db/migrate/20231114090744_add_mobility_translations_to_vendor.rb b/db/migrate/20231114090744_add_mobility_translations_to_vendor.rb deleted file mode 100644 index ff09ed90..00000000 --- a/db/migrate/20231114090744_add_mobility_translations_to_vendor.rb +++ /dev/null @@ -1,25 +0,0 @@ -class AddMobilityTranslationsToVendor < ActiveRecord::Migration[6.1] - def change - if ActiveRecord::Base.connection.table_exists?('spree_vendor_translations') - if ActiveRecord::Migration.connection.index_exists?(:spree_vendors_translations, :spree_vendor_id) - remove_index :spree_vendor_translations, column: :spree_vendor_id, if_exists: true - end - else - create_table :spree_vendor_translations do |t| - # Translated attribute(s) - t.string :name - t.text :about_us - t.text :contact_us - t.string :slug - - t.string :locale, null: false - t.references :spree_vendor, null: false, foreign_key: true, index: false - - t.timestamps null: false - end - - add_index :spree_vendor_translations, :locale, name: :index_spree_vendor_translations_on_locale - add_index :spree_vendor_translations, [:locale, :slug], unique: true, name: 'vendor_unique_slug_per_locale' - end - end -end diff --git a/db/migrate/20231114093444_transfer_vendor_data_to_translatable_tables.rb b/db/migrate/20231114093444_transfer_vendor_data_to_translatable_tables.rb deleted file mode 100644 index 9fdf74a0..00000000 --- a/db/migrate/20231114093444_transfer_vendor_data_to_translatable_tables.rb +++ /dev/null @@ -1,11 +0,0 @@ -class TransferVendorDataToTranslatableTables < ActiveRecord::Migration[6.1] - TRANSLATION_MIGRATION = Spree::TranslationMigrations.new(Spree::Vendor, 'en') - - def up - TRANSLATION_MIGRATION.transfer_translation_data - end - - def down - TRANSLATION_MIGRATION.revert_translation_data_transfer - end -end diff --git a/db/migrate/20250624185218_setup_spree_multi_vendor_open_source.rb b/db/migrate/20250624185218_setup_spree_multi_vendor_open_source.rb new file mode 100644 index 00000000..0c1b77cf --- /dev/null +++ b/db/migrate/20250624185218_setup_spree_multi_vendor_open_source.rb @@ -0,0 +1,106 @@ +class SetupSpreeMultiVendorOpenSource < ActiveRecord::Migration[7.2] + def change + create_table :spree_vendors, if_not_exists: true do |t| + t.string :name, index: true, null: false + t.string :original_name, index: true + t.string :state, index: true, null: false + + if t.respond_to? :jsonb + t.jsonb :public_metadata + t.jsonb :private_metadata + else + t.json :public_metadata + t.json :private_metadata + end + + t.integer :products_count, default: 0, null: false, index: true + t.decimal :sales_total, precision: 8, scale: 2, default: "0.0", null: false, index: true + t.decimal :commission_total, precision: 8, scale: 2, default: "0.0", null: false, index: true + + t.bigint :returns_address_id, index: true + t.string :contact_person_email, index: true + t.bigint :invited_by_id, index: true + t.bigint :billing_address_id, index: true + t.string :billing_email + + t.text :preferences + t.decimal :platform_fee, precision: 10, scale: 2, default: 30.0, null: false + t.string :payouts_schedule_interval, default: 'monthly', null: false + + t.datetime :deleted_at, index: true + t.timestamps + end + + create_table :spree_external_categories, if_not_exists: true do |t| + t.string :name, null: false, index: true + t.bigint :taxon_id, index: true + t.bigint :vendor_id, index: true + + t.string :external_id, index: true + t.string :type, null: false, index: true + + t.datetime :deleted_at, index: true + t.timestamps + end + + create_table :spree_platform_fees, if_not_exists: true do |t| + t.bigint :order_id, null: false, index: true + t.string :feeable_type, null: false + t.bigint :feeable_id, null: false + t.string :label, index: true + t.boolean :active, default: true, index: true + t.decimal :amount, precision: 10, scale: 2, null: false + t.decimal :rate, precision: 5, scale: 2 + + t.timestamps + end + + add_index :spree_platform_fees, %w[feeable_id feeable_type], name: "index_spree_platform_fees_on_feeable_id_and_feeable_type", if_not_exists: true + + %w[ + spree_line_items + spree_orders + spree_payments + spree_products + spree_reports + spree_shipments + spree_shipping_methods + spree_stock_locations + spree_webhooks_subscribers + ].each do |table| + if table_exists?(table) + add_column table, :vendor_id, :bigint, if_not_exists: true + add_index table, :vendor_id, if_not_exists: true + end + end + + %w[ + spree_product_properties + spree_products + spree_shipments + spree_shipping_methods + spree_stock_items + spree_variants + spree_assets + spree_orders + spree_line_items + ].each do |table| + if table_exists?(table) + add_column table, :external_id, :string, if_not_exists: true + add_index table, :external_id, if_not_exists: true + end + end + + add_column :spree_products, :platform_fee, :decimal, precision: 10, scale: 2, null: true, if_not_exists: true + + add_column :spree_orders, :platform_fee_total, :decimal, precision: 10, scale: 2, default: "0.0", if_not_exists: true + add_column :spree_orders, :parent_id, :bigint, if_not_exists: true + add_index :spree_orders, :parent_id, if_not_exists: true + add_column :spree_orders, :platform_fee_reverse_total, :decimal, precision: 10, scale: 2, default: "0.0", if_not_exists: true + + add_column :spree_line_items, :platform_fee_total, :decimal, precision: 10, scale: 2, default: "0.0", if_not_exists: true + add_column :spree_line_items, :platform_fee_rate, :decimal, precision: 5, scale: 2, if_not_exists: true + add_column :spree_line_items, :platform_fee_per_unit, :decimal, precision: 10, scale: 2, default: "0.0", if_not_exists: true + add_column :spree_line_items, :platform_fee_reverse_total, :decimal, precision: 10, scale: 2, default: "0.0", if_not_exists: true + end +end diff --git a/db/migrate/20250729094648_add_slug_to_spree_vendors.rb b/db/migrate/20250729094648_add_slug_to_spree_vendors.rb new file mode 100644 index 00000000..9dda0987 --- /dev/null +++ b/db/migrate/20250729094648_add_slug_to_spree_vendors.rb @@ -0,0 +1,6 @@ +class AddSlugToSpreeVendors < ActiveRecord::Migration[7.2] + def change + add_column :spree_vendors, :slug, :string, if_not_exists: true + add_index :spree_vendors, :slug, unique: true, if_not_exists: true + end +end diff --git a/db/migrate/20250731115051_add_vendor_id_to_spree_exports.rb b/db/migrate/20250731115051_add_vendor_id_to_spree_exports.rb new file mode 100644 index 00000000..b17798a5 --- /dev/null +++ b/db/migrate/20250731115051_add_vendor_id_to_spree_exports.rb @@ -0,0 +1,7 @@ +class AddVendorIdToSpreeExports < ActiveRecord::Migration[7.2] + def change + unless column_exists?(:spree_exports, :vendor_id) + add_reference :spree_exports, :vendor + end + end +end diff --git a/db/migrate/20251112115854_create_spree_vendor_translations.rb b/db/migrate/20251112115854_create_spree_vendor_translations.rb new file mode 100644 index 00000000..c56bc152 --- /dev/null +++ b/db/migrate/20251112115854_create_spree_vendor_translations.rb @@ -0,0 +1,12 @@ +class CreateSpreeVendorTranslations < ActiveRecord::Migration[7.2] + def change + create_table :spree_vendor_translations do |t| + t.string :locale, null: false + t.references :spree_vendor, null: false + + t.timestamps + end + + add_index :spree_vendor_translations, %i[spree_vendor_id locale], unique: true + end +end diff --git a/lib/generators/spree_multi_vendor/install/install_generator.rb b/lib/generators/spree_multi_vendor/install/install_generator.rb index fb46267d..0a6a186a 100644 --- a/lib/generators/spree_multi_vendor/install/install_generator.rb +++ b/lib/generators/spree_multi_vendor/install/install_generator.rb @@ -1,19 +1,24 @@ module SpreeMultiVendor module Generators class InstallGenerator < Rails::Generators::Base + class_option :migrate, type: :boolean, default: true - class_option :auto_run_migrations, type: :boolean, default: false + source_root File.expand_path('templates', __dir__) + + def copy_initializer + template 'config/initializers/spree_multi_vendor.rb', 'config/initializers/spree_multi_vendor.rb' + end def add_migrations run 'bundle exec rake railties:install:migrations FROM=spree_multi_vendor' end def run_migrations - run_migrations = options[:auto_run_migrations] || ['', 'y', 'Y'].include?(ask 'Would you like to run the migrations now? [Y/n]') + run_migrations = options[:migrate] || ['', 'y', 'Y'].include?(ask('Would you like to run the migrations now? [Y/n]')) if run_migrations - run 'bundle exec rake db:migrate' + run 'bin/rails db:migrate' else - puts 'Skipping rake db:migrate, don\'t forget to run it!' + puts 'Skipping rails db:migrate, don\'t forget to run it!' end end end diff --git a/lib/generators/spree_multi_vendor/install/templates/config/initializers/spree_multi_vendor.rb b/lib/generators/spree_multi_vendor/install/templates/config/initializers/spree_multi_vendor.rb new file mode 100644 index 00000000..9b3be028 --- /dev/null +++ b/lib/generators/spree_multi_vendor/install/templates/config/initializers/spree_multi_vendor.rb @@ -0,0 +1,4 @@ +Rails.application.config.after_initialize do + Spree.permissions.assign(:admin, SpreeMultiVendor::PermissionSets::SuperUser) + Spree.permissions.assign(:vendor, SpreeMultiVendor::PermissionSets::VendorUser) +end diff --git a/lib/generators/spree_multi_vendor/mailers_preview/mailers_preview_generator.rb b/lib/generators/spree_multi_vendor/mailers_preview/mailers_preview_generator.rb deleted file mode 100644 index 7bf86532..00000000 --- a/lib/generators/spree_multi_vendor/mailers_preview/mailers_preview_generator.rb +++ /dev/null @@ -1,22 +0,0 @@ -require 'rails/generators' -require 'spree/core' - -module SpreeMultiVendor - module Generators - class MailersPreviewGenerator < Rails::Generators::Base - desc 'Generates mailers preview for development proposes' - - def self.source_paths - [ - File.expand_path('templates', __dir__) - ] - end - - def copy_mailers_previews - preview_path = Rails.application.config.action_mailer.preview_path || 'test/mailers/previews' - - template 'mailers/previews/vendor_notification_preview.rb', "#{preview_path}/vendor_notification_preview.rb" - end - end - end -end diff --git a/lib/generators/spree_multi_vendor/mailers_preview/templates/mailers/previews/vendor_notification_preview.rb b/lib/generators/spree_multi_vendor/mailers_preview/templates/mailers/previews/vendor_notification_preview.rb deleted file mode 100644 index 7acae268..00000000 --- a/lib/generators/spree_multi_vendor/mailers_preview/templates/mailers/previews/vendor_notification_preview.rb +++ /dev/null @@ -1,5 +0,0 @@ -class VendorNotificationPreview < ActionMailer::Preview - def vendor_notification_email - Spree::VendorMailer.vendor_notification_email(Spree::Order.complete.first.id, Spree::Vendor.first.id) - end -end diff --git a/lib/spree_multi_vendor.rb b/lib/spree_multi_vendor.rb index 08f5988a..f0ed14be 100644 --- a/lib/spree_multi_vendor.rb +++ b/lib/spree_multi_vendor.rb @@ -1,16 +1,13 @@ require 'spree_core' -require 'spree_backend' -require 'spree_emails' +require 'spree_extension' require 'spree_multi_vendor/engine' require 'spree_multi_vendor/version' -require 'spree_extension' -require 'deface' +require 'spree_multi_vendor/configuration' module SpreeMultiVendor - # TODO: this should be moved into preferences - def self.vendorized_models - SpreeMultiVendor::Config[:vendorized_models].map(&:classify).map do |class_name| - "::Spree::#{class_name}".safe_constantize - end.compact.uniq + mattr_accessor :queue + + def self.queue + @@queue ||= Spree.queues.default end end diff --git a/lib/spree_multi_vendor/configuration.rb b/lib/spree_multi_vendor/configuration.rb index 980c0519..8b908eec 100644 --- a/lib/spree_multi_vendor/configuration.rb +++ b/lib/spree_multi_vendor/configuration.rb @@ -1,27 +1,6 @@ module SpreeMultiVendor - class Configuration - DEFAULT_VENDORIZED_MODELS ||= %w[product variant stock_location shipping_method].freeze - - attr_accessor :vendorized_models - - def initialize - self.vendorized_models = DEFAULT_VENDORIZED_MODELS - end - - def configure - yield(self) if block_given? - end - - def get(preference) - send(preference) - end - - alias [] get - - def set(preference, value) - send("#{preference}=", value) - end - - alias []= set + class Configuration < Spree::Preferences::Configuration + preference :shopify_app_name, :string, default: 'Vendo Connect' + preference :default_policies, :array, default: ['returns_policy'] end end diff --git a/lib/spree_multi_vendor/engine.rb b/lib/spree_multi_vendor/engine.rb index 8dba0398..4b3d101a 100644 --- a/lib/spree_multi_vendor/engine.rb +++ b/lib/spree_multi_vendor/engine.rb @@ -1,5 +1,3 @@ -require_relative 'configuration' - module SpreeMultiVendor class Engine < Rails::Engine require 'spree/core' @@ -15,8 +13,12 @@ class Engine < Rails::Engine SpreeMultiVendor::Config = SpreeMultiVendor::Configuration.new end - config.after_initialize do - ::Spree::PermittedAttributes.product_attributes << :vendor_id if SpreeMultiVendor::Config[:vendorized_models].include?('product') + initializer 'spree_multi_vendor.assets' do |app| + if app.config.respond_to?(:assets) + app.config.assets.paths << root.join('app/javascript') + app.config.assets.paths << root.join('vendor/javascript') + app.config.assets.precompile += %w[spree_multi_vendor_manifest] + end end def self.activate @@ -25,10 +27,6 @@ def self.activate end end - def self.api_v1_available? - @@api_v1_available ||= Gem::Specification.find_all_by_name('spree_api_v1').any? - end - - config.to_prepare &method(:activate).to_proc + config.to_prepare(&method(:activate).to_proc) end end diff --git a/lib/spree_multi_vendor/factories.rb b/lib/spree_multi_vendor/factories.rb index 2934112c..a286d57e 100644 --- a/lib/spree_multi_vendor/factories.rb +++ b/lib/spree_multi_vendor/factories.rb @@ -1,7 +1,4 @@ -FactoryBot.define do - GEM_ROOT = File.dirname(File.dirname(File.dirname(__FILE__))) +require 'factory_bot' +require 'factory_bot_rails' - Dir[File.join(GEM_ROOT, 'spec', 'factories', '**', '*.rb')].each do |factory| - require(factory) - end -end +Dir[File.join(__dir__, 'factories', '**', '*.rb')].each { |file| require file } diff --git a/lib/spree_multi_vendor/factories/billing_address_factory.rb b/lib/spree_multi_vendor/factories/billing_address_factory.rb new file mode 100644 index 00000000..f70181fd --- /dev/null +++ b/lib/spree_multi_vendor/factories/billing_address_factory.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :billing_address, class: Spree::BillingAddress, parent: :address do + trait :skip_mainstreet_validation do + skip_mainstreet_validation { true } + end + end +end diff --git a/lib/spree_multi_vendor/factories/country_factory.rb b/lib/spree_multi_vendor/factories/country_factory.rb new file mode 100644 index 00000000..9f204cd6 --- /dev/null +++ b/lib/spree_multi_vendor/factories/country_factory.rb @@ -0,0 +1,43 @@ +FactoryBot.define do + factory :canada_country, class: Spree::Country do + iso { 'CA' } + iso3 { 'CAN' } + name { 'Canada' } + iso_name { 'CANADA' } + zipcode_required { true } + end + + factory :usa_country, class: Spree::Country do + iso { 'US' } + iso3 { 'USA' } + name { 'United States' } + iso_name { 'UNITED STATES' } + states_required { true } + zipcode_required { true } + end + + factory :poland_country, class: Spree::Country do + iso { 'PL' } + iso3 { 'POL' } + name { 'Poland' } + iso_name { 'POLAND' } + zipcode_required { true } + end + + factory :uk_country, class: Spree::Country do + iso { 'GB' } + iso3 { 'GBR' } + name { 'United Kingdom' } + iso_name { 'UNITED KINGDOM' } + zipcode_required { true } + end + + factory :ireland_country, class: Spree::Country do + iso { 'IE' } + iso3 { 'IRL' } + name { 'Ireland' } + iso_name { 'UNITED KINGDOM' } + zipcode_required { false } + states_required { true } + end +end diff --git a/lib/spree_multi_vendor/factories/marketplace_commission_factory.rb b/lib/spree_multi_vendor/factories/marketplace_commission_factory.rb new file mode 100644 index 00000000..b8c66a55 --- /dev/null +++ b/lib/spree_multi_vendor/factories/marketplace_commission_factory.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :marketplace_commission, class: Spree::MarketplaceCommission do + amount { 10.0 } + active { true } + label { Spree::MarketplaceCommission::ITEM_LABEL } + order { build(:order) } + feeable { build(:line_item) } + end +end diff --git a/lib/spree_multi_vendor/factories/order_factory.rb b/lib/spree_multi_vendor/factories/order_factory.rb new file mode 100644 index 00000000..ba067da2 --- /dev/null +++ b/lib/spree_multi_vendor/factories/order_factory.rb @@ -0,0 +1,9 @@ +if FactoryBot.factories.registered?(:order) + FactoryBot.modify do + factory :order, class: Spree::Order do + trait :with_external_id do + external_id { SecureRandom.uuid } + end + end + end +end diff --git a/lib/spree_multi_vendor/factories/refund_factory.rb b/lib/spree_multi_vendor/factories/refund_factory.rb new file mode 100644 index 00000000..a24cbd61 --- /dev/null +++ b/lib/spree_multi_vendor/factories/refund_factory.rb @@ -0,0 +1,7 @@ +if FactoryBot.factories.registered?(:refund) + FactoryBot.modify do + factory :refund, class: Spree::Refund do + association(:refunder, factory: :admin_user) + end + end +end diff --git a/lib/spree_multi_vendor/factories/returns_address_factory.rb b/lib/spree_multi_vendor/factories/returns_address_factory.rb new file mode 100644 index 00000000..396e5629 --- /dev/null +++ b/lib/spree_multi_vendor/factories/returns_address_factory.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :returns_address, class: Spree::ReturnsAddress, parent: :address do + trait :skip_mainstreet_validation do + skip_mainstreet_validation { true } + end + end +end diff --git a/lib/spree_multi_vendor/factories/vendor_customer_return_factory.rb b/lib/spree_multi_vendor/factories/vendor_customer_return_factory.rb new file mode 100644 index 00000000..d5bea128 --- /dev/null +++ b/lib/spree_multi_vendor/factories/vendor_customer_return_factory.rb @@ -0,0 +1,37 @@ +FactoryBot.define do + # for the case when you want to supply existing return items instead of generating some + factory :vendor_customer_return_without_return_items, class: Spree::CustomerReturn do + association(:stock_location, factory: :stock_location) + + before(:create) do |customer_return, _evaluator| + if customer_return.store.nil? + default_store = Spree::Store.default.persisted? ? Spree::Store.default : nil + store = default_store || create(:store) + + customer_return.store = store + end + end + + factory :vendor_customer_return, class: Spree::CustomerReturn do + transient do + line_items_count { 1 } + return_items_count { line_items_count } + end + + before(:create) do |customer_return, evaluator| + shipped_order = create(:vendor_shipped_order, line_items_count: evaluator.line_items_count, store: customer_return.store, vendor: customer_return.stock_location.vendor) + + shipped_order.inventory_units.take(evaluator.return_items_count).each do |inventory_unit| + return_authorization = create(:return_authorization, order: shipped_order, stock_location: customer_return.stock_location) + customer_return.return_items << build(:return_item, inventory_unit: inventory_unit, return_authorization: return_authorization) + end + end + + factory :vendor_customer_return_with_accepted_items do + after(:create) do |customer_return| + customer_return.return_items.each(&:accept!) + end + end + end + end +end diff --git a/lib/spree_multi_vendor/factories/vendor_factory.rb b/lib/spree_multi_vendor/factories/vendor_factory.rb new file mode 100644 index 00000000..03d542f5 --- /dev/null +++ b/lib/spree_multi_vendor/factories/vendor_factory.rb @@ -0,0 +1,39 @@ +FactoryBot.define do + factory :vendor, class: Spree::Vendor do + name { FFaker::Company.name } + contact_person_email { FFaker::Internet.email } + invited_by_email { false } + state { :approved } + + factory :approved_vendor do + state { :approved } + end + + factory :onboarding_vendor do + state { :onboarding } + end + + factory :rejected_vendor do + state { :rejected } + end + + factory :invited_vendor do + invited_by_email { true } + state { :invited } + end + + trait :with_logo do + before(:create) do |vendor| + file = File.new(Spree::Core::Engine.root + 'spec/fixtures' + 'thinking-cat.jpg') + vendor.logo.attach(io: file, filename: 'logo', content_type: 'image/jpeg') + end + end + + trait :with_cover_photo do + before(:create) do |vendor| + file = File.new(Spree::Core::Engine.root + 'spec/fixtures' + 'thinking-cat.jpg') + vendor.cover_photo.attach(io: file, filename: 'logo', content_type: 'image/jpeg') + end + end + end +end diff --git a/lib/spree_multi_vendor/factories/vendor_line_item_factory.rb b/lib/spree_multi_vendor/factories/vendor_line_item_factory.rb new file mode 100644 index 00000000..6d46daec --- /dev/null +++ b/lib/spree_multi_vendor/factories/vendor_line_item_factory.rb @@ -0,0 +1,16 @@ +FactoryBot.define do + factory :vendor_line_item, class: Spree::LineItem, parent: :line_item do + product do + price_currency = order&.currency || Spree::Store.default.default_currency + product_vendor = vendor || order.vendor || create(:approved_vendor) + + product = if order&.store&.present? + create(:vendor_product_in_stock, stores: [order.store], vendor: product_vendor, price_currency: price_currency) + else + create(:vendor_product_in_stock, vendor: product_vendor, price_currency: price_currency) + end + + product + end + end +end diff --git a/lib/spree_multi_vendor/factories/vendor_order_factory.rb b/lib/spree_multi_vendor/factories/vendor_order_factory.rb new file mode 100644 index 00000000..d6515665 --- /dev/null +++ b/lib/spree_multi_vendor/factories/vendor_order_factory.rb @@ -0,0 +1,202 @@ +FactoryBot.define do + factory :splitted_order, parent: :completed_order_with_totals do + state { 'splitted' } + + transient do + payment_gateway { create(:credit_card_payment_method) } + end + + after(:create) do |order, evaluator| + create(:payment, + order: order, + amount: order.total, + payment_method: evaluator.payment_gateway, + ) + end + end + + factory :placed_order, parent: :order_with_line_items do + state { 'confirm' } + completed_at { Time.current } + + after(:create) do |order, evaluator| + create( + :payment, + amount: order.total, + order: order, + payment_method: evaluator.payment_gateway + ) + end + end + + factory :placed_order_with_vendor_items, parent: :order do + state { 'confirm' } + completed_at { Time.current } + + bill_address + ship_address + + transient do + line_items_count { 1 } + shipment_cost { 100 } + vendor { create(:approved_vendor) } + payment_gateway { create(:credit_card_payment_method) } + end + + after(:create) do |order, evaluator| + create_list( + :vendor_line_item, + evaluator.line_items_count, + order: order, + price: evaluator.line_items_price, + vendor: evaluator.vendor, + platform_fee_total: evaluator.line_items_price * 0.2 + ) + + order.line_items.reload + + create(:vendor_shipment, order: order, cost: evaluator.shipment_cost, vendor: evaluator.vendor) + order.shipments.reload + + order.update_with_updater! + + create( + :payment, + amount: order.total, + order: order, + payment_method: evaluator.payment_gateway + ) + end + end + + factory :vendor_order, parent: :order do + parent { create(:order) } + vendor { create(:approved_vendor) } + end + + factory :vendor_order_with_totals, class: Spree::Order, parent: :order do + parent { create(:order) } + + transient do + source { nil } + end + + after(:create) do |order, evaluator| + create(:vendor_line_item, + order: order, + price: evaluator.line_items_price, + vendor: order.vendor) + + order.line_items.reload # to ensure order.line_items is accessible after + end + end + + factory :vendor_order_with_line_items, class: Spree::Order, parent: :order do + bill_address + ship_address + vendor { create(:approved_vendor) } + parent { create(:order) } + + transient do + line_items_count { 1 } + without_line_items { false } + shipment_cost { 100 } + shipping_method_filter { Spree::ShippingMethod::DISPLAY_ON_FRONT_END } + end + + after(:create) do |order, evaluator| + unless evaluator.without_line_items + create_list(:vendor_line_item, evaluator.line_items_count, + order: order, + price: evaluator.line_items_price, + vendor: order.vendor, + platform_fee_total: evaluator.line_items_price * 0.2) + + order.line_items.reload + end + + create(:vendor_shipment, order: order, cost: evaluator.shipment_cost, vendor: order.vendor) + order.shipments.reload + + order.update_with_updater! + end + + factory :vendor_completed_order_with_totals do + state { 'complete' } + + after(:create) do |order, evaluator| + order.refresh_shipment_rates(evaluator.shipping_method_filter) + order.update_column(:completed_at, Time.current) + end + + factory :vendor_completed_order_with_pending_payment do + after(:create) do |order| + payment_method = if order.parent.present? && order.parent.payments.first&.payment_method + order.parent.payments.first.payment_method + else + SpreeStripe::Gateway.first || create(:credit_card_payment_method, stores: [order.store]) + end + + create(:payment, + amount: order.total, + order: order, + payment_method: payment_method) + end + end + + factory :vendor_completed_order_with_store_credit_payment do + after(:create) do |order| + store_credit = create(:store_credit, amount: order.total, store: order.store, user: order.user) + payment_method = create(:store_credit_payment_method, stores: [order.store]) + + create(:store_credit_payment, amount: order.total, order: order, source: store_credit, payment_method: payment_method) + end + end + + factory :vendor_order_ready_to_ship do + payment_state { 'paid' } + shipment_state { 'ready' } + + transient do + payment_gateway { nil } + with_payment { true } + end + + after(:create) do |order, evaluator| + payment_method = if order.parent.present? && order.parent.payments.first&.payment_method + order.parent.payments.first.payment_method + else + evaluator.payment_gateway || order.store.payment_methods.where.not(type: 'Spree::PaymentMethod::StoreCredit').first || create(:credit_card_payment_method, stores: [order.store]) + end + + if evaluator.with_payment + create( + :payment, + amount: order.total, + order: order, + state: 'completed', + payment_method: payment_method + ) + end + + order.shipments.each do |shipment| + shipment.inventory_units.update_all state: 'on_hand' + shipment.update_column('state', 'ready') + end + order.reload + end + + factory :vendor_shipped_order do + after(:create) do |order| + order.shipments.each do |shipment| + shipment.inventory_units.update_all state: 'shipped' + shipment.update_column('state', 'shipped') + end + order.update_column('shipment_state', 'shipped') + order.reload + end + end + end + end + end +end diff --git a/lib/spree_multi_vendor/factories/vendor_product_factory.rb b/lib/spree_multi_vendor/factories/vendor_product_factory.rb new file mode 100644 index 00000000..808fae60 --- /dev/null +++ b/lib/spree_multi_vendor/factories/vendor_product_factory.rb @@ -0,0 +1,51 @@ +FactoryBot.define do + factory :vendor_product, class: Spree::Product do + sequence(:name) { |n| "Product #{n}#{Kernel.rand(9999)}" } + description { generate(:random_description) } + price { 19.99 } + cost_price { 17.00 } + sku { generate(:sku) } + available_on { 1.year.ago } + make_active_at { 1.year.ago } + deleted_at { nil } + shipping_category { |r| Spree::ShippingCategory.first || r.association(:shipping_category) } + status { 'active' } + tax_category { |r| Spree::TaxCategory.first || r.association(:tax_category) } + vendor { create(:vendor) } + + transient do + price_currency { Spree::Store.default.default_currency } + end + + before(:create) do |product| + if product.stores.empty? + default_store = Spree::Store.default.persisted? ? Spree::Store.default : nil + store = default_store || create(:store) + + product.stores << [store] + end + end + + after(:create) do |product, evaluator| + product.vendor.stock_locations.each do |stock_location| + stock_location.propagate_variant(product.master) unless stock_location.stock_items.exists?(variant: product.master) + end + + product_price = product.price_in(evaluator.price_currency) + product_price.amount = evaluator.price + product_price.save! + end + + factory :vendor_product_in_stock do + after :create do |product| + product.master.stock_items.first.adjust_count_on_hand(10) + end + + trait :without_backorder do + after :create do |product| + product.master.stock_items.update_all(backorderable: false) + end + end + end + end +end diff --git a/lib/spree_multi_vendor/factories/vendor_rule_factory.rb b/lib/spree_multi_vendor/factories/vendor_rule_factory.rb new file mode 100644 index 00000000..35182cf2 --- /dev/null +++ b/lib/spree_multi_vendor/factories/vendor_rule_factory.rb @@ -0,0 +1,19 @@ +FactoryBot.define do + factory :vendor_taxon_rule, class: SpreeMultiVendor::TaxonRules::Vendor do + trait :is_equal_to do + match_policy { :is_equal_to } + end + + trait :is_not_equal_to do + match_policy { :is_not_equal_to } + end + + trait :contains do + match_policy { :contains } + end + + trait :does_not_contain do + match_policy { :does_not_contain } + end + end +end diff --git a/lib/spree_multi_vendor/factories/vendor_shipment_factory.rb b/lib/spree_multi_vendor/factories/vendor_shipment_factory.rb new file mode 100644 index 00000000..76d718b6 --- /dev/null +++ b/lib/spree_multi_vendor/factories/vendor_shipment_factory.rb @@ -0,0 +1,36 @@ +FactoryBot.define do + factory :vendor_shipment, class: Spree::Shipment do + tracking { 'U10000' } + cost { 100.00 } + state { 'pending' } + order + stock_location { vendor.stock_locations.first } + vendor + + transient do + shipping_method_attributes { {} } + map_inventory_units { true } + end + + after(:create) do |shipment, evaluator| + shipping_method_name = evaluator.shipping_method_attributes[:name] + shipping_method_scope = { name: shipping_method_name } if shipping_method_name.present? + + shipping_method = shipment.vendor.shipping_methods.where(shipping_method_scope).first || + create(:shipping_method, vendor: shipment.vendor, **evaluator.shipping_method_attributes) + + shipment.add_shipping_method(shipping_method, true) + + if evaluator.map_inventory_units + shipment.order.line_items.map do |line_item| + shipment.inventory_units.create( + order_id: shipment.order_id, + variant_id: line_item.variant_id, + line_item_id: line_item.id, + quantity: line_item.quantity + ) + end + end + end + end +end diff --git a/lib/spree_multi_vendor/factories/vendor_user_factory.rb b/lib/spree_multi_vendor/factories/vendor_user_factory.rb new file mode 100644 index 00000000..9562f2af --- /dev/null +++ b/lib/spree_multi_vendor/factories/vendor_user_factory.rb @@ -0,0 +1,13 @@ +FactoryBot.define do + factory :vendor_user, class: Spree.admin_user_class, parent: :admin_user do + without_admin_role + + transient do + vendor { nil } + end + + after(:create) do |user, evaluator| + evaluator.vendor.add_user(user) if evaluator.vendor.present? + end + end +end diff --git a/lib/spree_multi_vendor/factories/vendor_variant_factory.rb b/lib/spree_multi_vendor/factories/vendor_variant_factory.rb new file mode 100644 index 00000000..392c5345 --- /dev/null +++ b/lib/spree_multi_vendor/factories/vendor_variant_factory.rb @@ -0,0 +1,32 @@ +FactoryBot.define do + factory :vendor_variant, class: Spree::Variant do + price { 19.99 } + cost_price { 17.00 } + sku { generate(:sku) } + weight { generate(:random_float) } + height { generate(:random_float) } + width { generate(:random_float) } + depth { generate(:random_float) } + is_master { 0 } + track_inventory { true } + + product { |p| p.association(:vendor_product, stores: [create(:store)]) } + option_values { [create(:option_value)] } + + after(:create) do |variant| + variant.product.vendor.stock_locations.each do |stock_location| + stock_location.propagate_variant(variant) + end + end + + factory :vendor_variant_in_stock do + transient do + stock_count { 10 } + end + + after(:create) do |variant, evaluator| + variant.stock_items.first.adjust_count_on_hand(evaluator.stock_count) + end + end + end +end diff --git a/lib/spree_multi_vendor/testing_support/authorization_helpers.rb b/lib/spree_multi_vendor/testing_support/authorization_helpers.rb new file mode 100644 index 00000000..864b29dc --- /dev/null +++ b/lib/spree_multi_vendor/testing_support/authorization_helpers.rb @@ -0,0 +1,24 @@ +module SpreeMultiVendor + module AuthorizationHelpers + module Controller + def stub_vendor_authorization! + stub_authorization! + let(:admin_user) { vendor.users.first || create(:vendor_user, vendor: vendor) } + end + end + + module Request + def stub_vendor_authorization! + stub_authorization! + let(:admin_user) { vendor.users.first || create(:vendor_user, vendor: vendor) } + end + end + end +end + +RSpec.configure do |config| + config.extend SpreeMultiVendor::AuthorizationHelpers::Controller, type: :controller + config.extend SpreeMultiVendor::AuthorizationHelpers::Request, type: :feature + config.extend SpreeMultiVendor::AuthorizationHelpers::Request, type: :request + config.extend Spree::TestingSupport::AuthorizationHelpers::Request, type: :request +end diff --git a/lib/spree_multi_vendor/version.rb b/lib/spree_multi_vendor/version.rb index 22b591dc..d66b45f6 100644 --- a/lib/spree_multi_vendor/version.rb +++ b/lib/spree_multi_vendor/version.rb @@ -1,11 +1,7 @@ module SpreeMultiVendor - VERSION = '2.5.0' + VERSION = '3.0.0.alpha'.freeze - module_function - - # Returns the version of the currently loaded SpreeMultiVendor as a - # Gem::Version. - def version - Gem::Version.new VERSION + def gem_version + Gem::Version.new(VERSION) end end diff --git a/lib/tasks/vendors.rake b/lib/tasks/vendors.rake deleted file mode 100644 index 472a3cd5..00000000 --- a/lib/tasks/vendors.rake +++ /dev/null @@ -1,8 +0,0 @@ -namespace :spree_multi_vendor do - namespace :sample do - desc 'Create sample Vendor and Vendor User' - task create: :environment do - require File.join(File.dirname(__FILE__), '..', '..', 'db', 'default', 'vendors.rb') - end - end -end \ No newline at end of file diff --git a/lib/testing_support/authorization_helpers.rb b/lib/testing_support/authorization_helpers.rb new file mode 100644 index 00000000..864b29dc --- /dev/null +++ b/lib/testing_support/authorization_helpers.rb @@ -0,0 +1,24 @@ +module SpreeMultiVendor + module AuthorizationHelpers + module Controller + def stub_vendor_authorization! + stub_authorization! + let(:admin_user) { vendor.users.first || create(:vendor_user, vendor: vendor) } + end + end + + module Request + def stub_vendor_authorization! + stub_authorization! + let(:admin_user) { vendor.users.first || create(:vendor_user, vendor: vendor) } + end + end + end +end + +RSpec.configure do |config| + config.extend SpreeMultiVendor::AuthorizationHelpers::Controller, type: :controller + config.extend SpreeMultiVendor::AuthorizationHelpers::Request, type: :feature + config.extend SpreeMultiVendor::AuthorizationHelpers::Request, type: :request + config.extend Spree::TestingSupport::AuthorizationHelpers::Request, type: :request +end diff --git a/spec/.rubocop.yml b/spec/.rubocop.yml new file mode 100644 index 00000000..fb81f096 --- /dev/null +++ b/spec/.rubocop.yml @@ -0,0 +1,9 @@ +require: rubocop-rspec + +inherit_from: ../.rubocop.yml + +Metrics/BlockLength: + Enabled: false + +Style/BlockDelimiters: + Enabled: false diff --git a/spec/controllers/spree/account/orders_controller_spec.rb b/spec/controllers/spree/account/orders_controller_spec.rb new file mode 100644 index 00000000..869e7f03 --- /dev/null +++ b/spec/controllers/spree/account/orders_controller_spec.rb @@ -0,0 +1,58 @@ +require 'spec_helper' + +RSpec.describe Spree::Account::OrdersController, type: :controller do + let(:store) { Spree::Store.default } + + let!(:parent_order) { create(:splitted_order, user: user, store: store, payment_gateway: check_payment_method) } + let!(:order) { create(:vendor_order_ready_to_ship, parent: parent_order, user: user, store: store) } + + let(:check_payment_method) { create(:check_payment_method, stores: [store]) } + let(:user) { create(:user) } + + render_views + + before do + allow(controller).to receive(:current_store).and_return(store) + allow(controller).to receive(:spree_login_path).and_return('/login') + end + + describe '#show' do + subject { get :show, params: { id: parent_order.number } } + + context 'when user is logged in' do + before do + allow(controller).to receive(:try_spree_current_user).and_return(user) + allow(controller).to receive(:spree_current_user).and_return(user) + end + + it 'shows the order details' do + subject + expect(response).to have_http_status(:ok) + + expect(assigns(:order)).to eq(parent_order) + + expect(assigns(:shipments).length).to eq(2) + expect(assigns(:shipments)).to contain_exactly(order.shipments.first, parent_order.shipments.first) + end + + it 'renders the order details' do + subject + expect(response).to render_template(:show) + end + + context 'when order number does not exist' do + subject { get :show, params: { id: 'invalid' } } + + it 'raises ActiveRecord::RecordNotFound' do + expect { subject }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end + + context 'when user is not logged in' do + it 'redirects to login page' do + expect(subject).to have_http_status(302) + end + end + end +end diff --git a/spec/controllers/spree/admin/base_controller_spec.rb b/spec/controllers/spree/admin/base_controller_spec.rb new file mode 100644 index 00000000..138606ca --- /dev/null +++ b/spec/controllers/spree/admin/base_controller_spec.rb @@ -0,0 +1,46 @@ +require 'spec_helper' + +describe Spree::Admin::BaseController, type: :controller do + let(:store) { Spree::Store.default } + + describe '#current_vendor' do + let!(:vendor) { create(:approved_vendor) } + let(:admin_user) { create(:vendor_user, vendor: vendor) } + let(:store) { Spree::Store.default } + + subject { controller.current_vendor } + + context 'spree controllers with current store' do + before do + allow(controller).to receive(:try_spree_current_user).and_return(admin_user) + allow(controller).to receive(:current_store).and_return(store) + end + + context 'current_vendor_id session is set' do + before { session[:current_vendor_id] = vendor.id } + + it { is_expected.to eq(vendor) } + end + + context 'current_vendor_id session is not set' do + it { is_expected.to eq(vendor) } + end + + context 'store admin user' do + before do + store.add_user(admin_user) + end + + it { is_expected.to be_nil } + end + end + + context 'no user' do + before do + allow(controller).to receive(:current_store).and_return(store) + end + + it { is_expected.to be_nil } + end + end +end diff --git a/spec/controllers/spree/admin/dashboard_controller_spec.rb b/spec/controllers/spree/admin/dashboard_controller_spec.rb new file mode 100644 index 00000000..5194cdc3 --- /dev/null +++ b/spec/controllers/spree/admin/dashboard_controller_spec.rb @@ -0,0 +1,238 @@ +require 'spec_helper' + +describe Spree::Admin::DashboardController, type: :controller do + stub_authorization! + render_views + + let(:store) { Spree::Store.default } + + describe '#show' do + it 'renders welcome page' do + get :show + expect(response).to have_http_status(200) + end + end + + describe '#analytics' do + context 'view' do + it 'renders analytics' do + get :analytics + expect(response).to have_http_status(200) + end + end + + context 'with vendor' do + context 'missing vendor' do + let!(:vendor) { create(:approved_vendor) } + let!(:admin_user) { create(:vendor_user, vendor: vendor) } + + before do + get :analytics, params: { vendor_id: 'missing' } + end + + it 'fallbacks to default vendor for this user' do + expect(controller.current_vendor).to eq(vendor) + end + end + + context 'vendor from different account' do + let!(:vendor) { create(:approved_vendor) } + let!(:other_vendor) { create(:approved_vendor) } + let!(:admin_user) { create(:vendor_user, vendor: vendor) } + + before { get(:analytics, params: { vendor_id: other_vendor.id }) } + + it 'fallbacks to default vendor for this user' do + expect(controller.current_vendor).to eq(vendor) + end + end + end + + xcontext 'products_scope' do + let!(:product1) { create(:product_in_stock) } + let!(:product2) { create(:product_in_stock) } + let!(:other_product) { create(:product, stores: [create(:store)]) } + + subject do + @controller.instance_variable_get(:@products_scope) + end + + context 'without vendor' do + it 'is set to all products in the store' do + get :analytics + expect(subject).to contain_exactly(product1, product2) + end + end + + xcontext 'with vendors' do + let!(:vendor) { create(:approved_vendor) } + let!(:other_vendor) { create(:approved_vendor) } + let!(:admin_user) { create(:vendor_user, vendor: vendor) } + + before do + product1.update_column(:vendor_id, vendor.id) + product2.update_column(:vendor_id, other_vendor.id) + end + + it 'is set to products from the current store and vendor' do + get :analytics, params: { vendor_id: vendor.id } + expect(subject).to contain_exactly(product1) + end + end + end + + xcontext 'orders_scope' do + let!(:order1) { create(:completed_order_with_totals) } + let!(:order2) { create(:completed_order_with_totals) } + let!(:incomplete_order) { create(:order) } + let!(:other_store) { create(:store) } + let!(:order_in_other_store) { create(:completed_order_with_totals, store: other_store) } + + subject do + @controller.instance_variable_get(:@orders_scope) + end + + context 'currency' do + let!(:order_in_other_currency) { create(:completed_order_with_totals, currency: 'EUR') } + + context 'with default currency' do + it 'is set to all orders with the default currency' do + get :analytics + expect(subject.unscope(:group)).to contain_exactly(order1, order2) + end + end + + context 'with currency=EUR' do + it 'is set to all orders with the given currency' do + get :analytics, params: { analytics_currency: 'EUR' } + expect(subject.unscope(:group)).to contain_exactly(order_in_other_currency) + end + end + end + + xcontext 'with vendors' do + let!(:vendor) { create(:approved_vendor) } + let!(:other_vendor) { create(:approved_vendor) } + let!(:admin_user) { create(:vendor_user, vendor: vendor) } + let!(:order1) { create(:vendor_completed_order_with_totals, vendor: vendor) } + let!(:order2) { create(:vendor_completed_order_with_totals, vendor: vendor) } + + it 'is set to orders from the current store and vendor' do + get :analytics, params: { vendor_id: vendor.id } + expect(subject.unscope(:group)).to contain_exactly(order1) + end + end + + context 'grouping by time range' do + let!(:order_from_this_week) { create(:completed_order_with_totals) } + let!(:order_from_this_month) { create(:completed_order_with_totals) } + + before do + order_from_this_week.update_column(:completed_at, 5.days.ago) + order_from_this_month.update_column(:completed_at, 25.days.ago) + end + + context 'default time range (last 24 hours)' do + it 'is set to the orders from the current store completed in the last 24 hours' do + get :analytics + + num_orders_by_hour = subject.count.values + expect(num_orders_by_hour.sum).to eq(2) + end + end + end + end + + xcontext 'product_units_sold' do + let!(:incomplete_order) { create(:order) } + let!(:order_in_other_store) { create(:completed_order_with_totals, store: create(:store)) } + let!(:order1) { create(:completed_order_with_totals) } + let!(:order2) { create(:completed_order_with_totals) } + let(:product1) { order1.line_items.first.product } + let(:product2) { order2.line_items.first.product } + + subject do + @controller.instance_variable_get(:@product_units_sold) + end + + context 'currency' do + let!(:order_in_other_currency) { create(:completed_order_with_totals, currency: 'EUR') } + + context 'with default currency' do + it 'is set to all orders with the default currency' do + get :analytics + expect(subject.to_a).to contain_exactly([product1.name, 1], [product2.name, 1]) + end + end + + context 'with currency=EUR' do + it 'is set to all orders with the given currency' do + get :analytics, params: { analytics_currency: 'EUR' } + expect(subject.to_a).to contain_exactly([order_in_other_currency.line_items.take.product.name, 1]) + end + end + end + + xcontext 'with vendors' do + let!(:vendor) { create(:approved_vendor) } + let!(:other_vendor) { create(:approved_vendor) } + let!(:admin_user) { create(:vendor_user, vendor: vendor) } + + before do + order1.update_column(:vendor_id, vendor.id) + order2.update_column(:vendor_id, other_vendor.id) + end + + it 'is counts units sold for orders the current store and vendor' do + get :analytics, params: { vendor_id: vendor.id } + expect(subject.to_a).to contain_exactly([product1.name, 1]) + end + end + + context 'with orders outside of time range' do + let!(:order_from_this_week) { create(:completed_order_with_totals) } + let!(:order_from_this_month) { create(:completed_order_with_totals) } + let!(:order_from_this_week_product) { order_from_this_week.line_items.first.product } + let!(:order_from_this_month_product) { order_from_this_month.line_items.first.product } + + before do + order_from_this_week.update_column(:completed_at, 5.days.ago) + order_from_this_month.update_column(:completed_at, 25.days.ago) + end + + it 'returns items sold in last day by default' do + get :analytics + expect(subject.to_a).to contain_exactly([product1.name, 1], [product2.name, 1]) + end + + it 'items sold in last 7 days' do + get :analytics, params: { analytics_time_range: 7 } + expect(subject.to_a).to contain_exactly([product1.name, 1], [product2.name, 1], [order_from_this_week_product.name, 1]) + end + + it 'items sold in last 30 days' do + get :analytics, params: { analytics_time_range: 30 } + expect(subject.to_a).to contain_exactly([product1.name, 1], [product2.name, 1], + [order_from_this_week_product.name, 1], [order_from_this_month_product.name, 1]) + end + end + + context 'with more line items and quantity' do + let!(:order_from_this_week) { create(:completed_order_with_totals) } + let!(:order_from_this_week_product) { order_from_this_week.line_items.first.product } + let!(:order1_line_item) { create(:line_item, product: product1, order: order1, quantity: 4) } + + before do + order_from_this_week.update_column(:completed_at, 5.days.ago) + order_from_this_week.line_items.first.update_column(:quantity, 3) + order2.line_items.first.update_column(:quantity, 10) + end + + it 'returns items sold by summing quantities' do + get :analytics, params: { analytics_time_range: 7 } + expect(subject.to_a).to contain_exactly([product1.name, 5], [product2.name, 10], [order_from_this_week_product.name, 3]) + end + end + end + end +end diff --git a/spec/controllers/spree/admin/exports_controller_spec.rb b/spec/controllers/spree/admin/exports_controller_spec.rb new file mode 100644 index 00000000..156771ff --- /dev/null +++ b/spec/controllers/spree/admin/exports_controller_spec.rb @@ -0,0 +1,86 @@ +require 'spec_helper' + +RSpec.describe Spree::Admin::ExportsController, type: :controller do + stub_authorization! + render_views + + let(:vendor) { create(:approved_vendor) } + let(:admin_user) { create(:vendor_user, vendor: vendor) } + let(:store) { Spree::Store.default } + + before do + allow(controller).to receive(:current_vendor).and_return(vendor) + allow(controller).to receive(:try_spree_current_user).and_return(admin_user) + end + + describe '#new' do + it 'renders the new template' do + get :new, params: { export: { type: 'Spree::Exports::Orders' } } + expect(response).to have_http_status(:ok) + expect(response).to render_template(:new) + expect(assigns(:object)).to be_a_new(Spree::Export) + end + end + + describe '#create' do + let(:export_params) do + { + export: { + type: 'Spree::Exports::Orders', + search_params: { 'created_at_gt' => 1.week.ago.to_s }.to_json + } + } + end + + it 'creates a new export with vendor_id set' do + expect { + post :create, params: export_params + }.to change(Spree::Export, :count).by(1) + + export = Spree::Export.last + expect(export.vendor).to eq(vendor) + expect(export.user).to eq(admin_user) + expect(export.type).to eq('Spree::Exports::Orders') + expect(export.search_params).to be_present + expect(response).to redirect_to(spree.admin_exports_path) + end + + it 'sets flash message after successful creation' do + post :create, params: export_params + expect(flash[:success]).to eq(Spree.t('admin.export_created')) + end + end + + describe '#index' do + let(:other_vendor) { create(:approved_vendor) } + let!(:vendor_export) { create(:product_export, vendor: vendor, user: admin_user, store: store) } + let!(:other_vendor_export) { create(:product_export, vendor: other_vendor, store: store) } + let!(:admin_export) { create(:product_export, vendor: nil, store: store) } + + it 'lists only exports belonging to current vendor' do + get :index + expect(response).to have_http_status(:ok) + expect(response).to render_template(:index) + + expect(assigns(:collection)).to include(vendor_export) + expect(assigns(:collection)).not_to include(other_vendor_export) + expect(assigns(:collection)).not_to include(admin_export) + end + end + + describe '#show' do + let(:export) { create(:product_export, vendor: vendor, user: admin_user, store: store) } + + before do + allow_any_instance_of(Spree::Exports::Products).to receive_message_chain(:attachment, :url).and_return('http://example.com/test.csv') + end + + subject { get :show, params: { id: export.id } } + + it 'downloads the export' do + subject + expect(response).to have_http_status(:see_other) + expect(response.headers['Location']).to eq(export.attachment.url) + end + end +end diff --git a/spec/controllers/spree/admin/imports_controller_spec.rb b/spec/controllers/spree/admin/imports_controller_spec.rb new file mode 100644 index 00000000..e477ab17 --- /dev/null +++ b/spec/controllers/spree/admin/imports_controller_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper' + +RSpec.describe Spree::Admin::ImportsController, type: :controller do + stub_authorization! + render_views + + let(:vendor) { create(:approved_vendor) } + let(:admin_user) { create(:vendor_user, vendor: vendor) } + let(:store) { Spree::Store.default } + + let(:attachment) { Rack::Test::UploadedFile.new(File.join(Spree::Core::Engine.root, 'spec/fixtures/files', 'products_import.csv'), 'text/csv') } + let(:csv_content) { File.read(File.join(Spree::Core::Engine.root, 'spec/fixtures/files', 'products_import.csv')) } + + before do + allow(controller).to receive(:current_vendor).and_return(vendor) + allow(controller).to receive(:try_spree_current_user).and_return(admin_user) + end + + describe 'GET #new' do + it 'renders the new template' do + get :new, params: { import: { type: 'Spree::Imports::Products' } } + expect(response).to have_http_status(:ok) + expect(response).to render_template(:new) + expect(assigns(:object)).to be_a_new(Spree::Import) + end + end + + describe 'POST #create' do + let(:import_params) { { type: Spree::Import.available_types.first.to_s, attachment: attachment } } + + it 'creates a new import and redirects to show' do + expect { + post :create, params: { import: import_params } + }.to change(Spree::Import, :count).by(1) + + import = Spree::Import.last + expect(response).to redirect_to(spree.admin_import_path(import)) + expect(import.user).to eq(admin_user) + expect(import.owner).to eq(vendor) + expect(import.type).to eq(Spree::Import.available_types.first.to_s) + expect(import.attachment).to be_attached + expect(import.attachment.filename.to_s).to eq('products_import.csv') + end + end +end diff --git a/spec/controllers/spree/admin/orders_controller_spec.rb b/spec/controllers/spree/admin/orders_controller_spec.rb new file mode 100644 index 00000000..4efb27b0 --- /dev/null +++ b/spec/controllers/spree/admin/orders_controller_spec.rb @@ -0,0 +1,70 @@ +require 'spec_helper' + +describe Spree::Admin::OrdersController, type: :controller do + stub_authorization! + render_views + + let(:admin_user) { create(:admin_user) } + let(:store) { Spree::Store.default } + + before do + allow(controller).to receive(:current_ability).and_call_original + end + + describe 'GET #index' do + let(:vendor) { create(:vendor, name: 'Test Vendor') } + + let!(:main_order) { create(:completed_order_with_totals, store: store, vendor: nil) } + + let!(:parent_order_1) { create(:completed_order_with_totals, store: store, vendor: nil) } + let!(:vendor_order_1) { create(:vendor_completed_order_with_totals, store: store, vendor: vendor, parent: parent_order_1) } + + let!(:parent_order_2) { create(:completed_order_with_totals, store: store, vendor: nil) } + let!(:vendor_order_2) { create(:vendor_completed_order_with_totals, store: store, parent: parent_order_2) } + + context 'when vendor is present' do + it 'filters orders by vendor ID' do + get :index, params: { vendor_id: vendor.id } + + expect(assigns(:orders)).to contain_exactly(parent_order_1) + end + + it 'filters orders by vendor slug' do + get :index, params: { vendor_id: vendor.slug } + + expect(assigns(:orders)).to contain_exactly(parent_order_1) + end + end + + context 'when vendor is not present' do + it 'shows all orders' do + get :index, params: { vendor_id: nil } + + expect(assigns(:orders)).to contain_exactly(main_order, parent_order_1, parent_order_2) + end + end + + context 'when vendor_id is invalid' do + it 'redirects to the index page' do + get :index, params: { vendor_id: 'invalid-slug' } + + expect(response).to redirect_to(spree.admin_orders_path) + end + end + + context 'when current user is a vendor user' do + let(:vendor_user) { create(:vendor_user, vendor: vendor) } + + before do + allow(controller).to receive(:current_spree_user).and_return(vendor_user) + allow(controller).to receive(:current_vendor).and_return(vendor) + end + + it 'does not load vendor from params when current_vendor is present' do + get :index, params: { vendor_id: 'different-vendor-id' } + + expect(assigns(:vendor)).to be_nil + end + end + end +end diff --git a/spec/controllers/spree/admin/products_controller_spec.rb b/spec/controllers/spree/admin/products_controller_spec.rb new file mode 100644 index 00000000..72920e8d --- /dev/null +++ b/spec/controllers/spree/admin/products_controller_spec.rb @@ -0,0 +1,169 @@ +require 'spec_helper' + +describe Spree::Admin::ProductsController, type: :controller do + stub_authorization! + render_views + + let(:admin_user) { create(:admin_user) } + + before do + allow(controller).to receive(:current_ability).and_call_original + end + + let(:store) { Spree::Store.default } + + describe 'GET #index' do + let(:vendor) { create(:vendor, name: 'Test Vendor') } + let!(:non_vendor_product) { create(:product) } + let!(:vendor_product) { create(:product, vendor: vendor) } + + context 'when vendor is present' do + it 'filters products by vendor ID' do + get :index, params: { vendor_id: vendor.id } + + expect(assigns(:collection)).to contain_exactly(vendor_product) + end + + it 'filters products by vendor slug' do + get :index, params: { vendor_id: vendor.slug } + + expect(assigns(:collection)).to contain_exactly(vendor_product) + end + end + + context 'when vendor is not present' do + it 'does not filter products by vendor' do + get :index, params: { vendor_id: nil } + + expect(assigns(:collection)).to contain_exactly(vendor_product, non_vendor_product) + end + end + + context 'when vendor is not found' do + it 'redirects to the index page' do + get :index, params: { vendor_id: 'invalid-slug' } + + expect(response).to redirect_to(spree.admin_products_path) + end + end + end + + describe 'PUT #update' do + let!(:product) { create(:product, stores: [store], status: 'active') } + let(:product_params) { { status: 'draft', make_active_at: Time.current.beginning_of_day } } + let(:send_request) do + put :update, params: { + id: product.to_param, + product: product_params + } + end + + context 'updating existing variants' do + let(:color_option_type) { Spree::OptionType.find_by(name: 'color') || create(:option_type, name: 'color', presentation: 'Color') } + let(:size_option_type) { Spree::OptionType.find_by(name: 'size') || create(:option_type, name: 'size', presentation: 'Size') } + let(:red_option_value) { color_option_type.option_values.find_by(name: 'red') || create(:option_value, name: 'red', option_type: color_option_type) } + let(:blue_option_value) { color_option_type.option_values.find_by(name: 'blue') || create(:option_value, name: 'blue', option_type: color_option_type) } + let(:small_option_value) { size_option_type.option_values.find_by(name: 'small') || create(:option_value, name: 'small', option_type: size_option_type) } + let(:large_option_value) { size_option_type.option_values.find_by(name: 'large') || create(:option_value, name: 'large', option_type: size_option_type) } + + let(:variant1) { create(:variant, product: product, option_values: [red_option_value, small_option_value], price: 100) } + let(:variant2) { create(:variant, product: product, option_values: [blue_option_value, large_option_value], price: 100) } + + let(:variant1_stock_item) { variant1.stock_items.first } + let(:variant2_stock_item) { variant2.stock_items.first } + + let(:product_params) do + { + name: 'Product', + variants_attributes: { + '0' => { + prices_attributes: { + '0': { currency: 'PLN', amount: 10, id: variant1.price_in('PLN')&.id }, + '1': { currency: 'USD', amount: 20, id: variant1.price_in('USD')&.id } + }, + id: variant1.id, + stock_items_attributes: { + '0' => { + id: variant1_stock_item.id, + count_on_hand: 10, + stock_location_id: variant1_stock_item.stock_location_id, + } + }, + options: [ + { + id: red_option_value.id, + name: 'Color', + position: 1, + option_value_presentation: red_option_value.presentation, + option_value_name: red_option_value.name + }, + { + id: small_option_value.id, + name: 'Size', + position: 2, + option_value_presentation: small_option_value.presentation, + option_value_name: small_option_value.name + } + ] + }, + '1' => { + prices_attributes: { + '0': { currency: 'PLN', amount: 30, id: variant2.price_in('PLN')&.id }, + '1': { currency: 'USD', amount: 40, id: variant2.price_in('USD')&.id } + }, + id: variant2.id, + stock_items_attributes: { + '0' => { + id: variant2_stock_item.id, + count_on_hand: 20, + stock_location_id: variant2_stock_item.stock_location_id, + } + }, + options: [ + { + id: blue_option_value.id, + name: 'Color', + position: 1, + option_value_presentation: blue_option_value.presentation, + option_value_name: blue_option_value.name + }, + { + id: large_option_value.id, + name: 'Size', + position: 2, + option_value_presentation: large_option_value.presentation, + option_value_name: large_option_value.name + } + ] + } + } + } + end + end + + context 'with platform_fee param' do + let(:product_params) do + { + platform_fee: '22.4' + } + end + + it 'updates spree_products.platform_fee attribute' do + expect { send_request }.to change { product.reload.platform_fee }.from(nil).to(22.4) + end + end + end + + describe 'GET #bulk_modal' do + context 'as a vendor user' do + let(:vendor) { create(:approved_vendor) } + let(:admin_user) { create(:vendor_user, vendor: vendor) } + + it 'renders modal' do + get :bulk_modal + + expect(response).to render_template('bulk_modal') + end + end + end +end diff --git a/spec/controllers/spree/admin/refunds_controller_spec.rb b/spec/controllers/spree/admin/refunds_controller_spec.rb new file mode 100644 index 00000000..5aca246a --- /dev/null +++ b/spec/controllers/spree/admin/refunds_controller_spec.rb @@ -0,0 +1,39 @@ +require 'spec_helper' + +describe Spree::Admin::RefundsController, type: :controller do + stub_authorization! + render_views + + let(:admin_user) { create(:admin_user) } + + before do + allow(controller).to receive(:current_ability).and_return(Spree::Dependencies.ability_class.constantize.new(admin_user)) + end + + describe '#new' do + let(:payment) { create(:payment, state: 'completed', order: order) } + subject { get :new, params: { payment_id: payment.number, order_id: order.number } } + + before do + subject + end + + context 'when payment order is parent order' do + let(:order) { create(:completed_order_with_totals, state: 'splitted') } + + it 'should return error' do + expect(flash['error']).to eq('Authorization Failure') + expect(response).to be_redirect + end + end + + context 'when payment order is vendor order' do + let(:order) { create(:vendor_completed_order_with_totals) } + + it 'should not return error' do + expect(flash['error']).to be nil + expect(response).to be_successful + end + end + end +end diff --git a/spec/controllers/spree/admin/resource_controller_spec.rb b/spec/controllers/spree/admin/resource_controller_spec.rb new file mode 100644 index 00000000..7e6b3c6b --- /dev/null +++ b/spec/controllers/spree/admin/resource_controller_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +module Spree + module Admin + class ExampleResourcesController < Spree::Admin::ResourceController + def new + super + head :ok + end + + def model_class + Spree::ShippingMethod + end + end + end +end + +RSpec.describe Spree::Admin::ExampleResourcesController, type: :controller do + stub_authorization! + + let(:vendor) { create(:vendor) } + let(:admin_user) { create(:vendor_user, vendor: vendor) } + + after(:all) do + Rails.application.reload_routes! + end + + before do + Spree::Core::Engine.routes.draw do + namespace :admin do + resources :example_resources + end + end + end + + describe '#new' do + subject { get :new } + + it 'assigns a vendor to the resource' do + subject + expect(assigns(:object).vendor).to eq(vendor) + end + end +end diff --git a/spec/controllers/spree/admin/shipping_methods_controller_spec.rb b/spec/controllers/spree/admin/shipping_methods_controller_spec.rb new file mode 100644 index 00000000..556a1c7a --- /dev/null +++ b/spec/controllers/spree/admin/shipping_methods_controller_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +RSpec.describe Spree::Admin::ShippingMethodsController, type: :controller do + stub_authorization! + render_views + + let!(:vendor) { create(:vendor) } + let(:admin_user) { create(:vendor_user, vendor: vendor) } + + describe 'GET #index' do + let!(:vendor_shipping_methods) { create_list(:shipping_method, 2, vendor: vendor) } + let!(:other_shipping_method) { create(:shipping_method) } + + it 'renders vendor shipping methods' do + get :index + expect(response).to render_template(:index) + expect(assigns(:collection)).to contain_exactly(*vendor_shipping_methods) + end + end + + describe 'POST #create' do + let(:shipping_method_attributes) do + attributes_for( + :shipping_method, + zone_ids: [zone.id], + shipping_category_ids: [shipping_category.id], + calculator_type: 'Spree::Calculator::Shipping::FlatRate' + ) + end + + let(:zone) { create(:zone) } + let(:shipping_category) { create(:shipping_category) } + + it 'creates a new vendor shipping method' do + expect { post :create, params: { shipping_method: shipping_method_attributes } }.to change(Spree::ShippingMethod, :count).by(1) + + expect(Spree::ShippingMethod.last.vendor).to eq(vendor) + end + end +end diff --git a/spec/controllers/spree/admin/taxons_controller_spec.rb b/spec/controllers/spree/admin/taxons_controller_spec.rb new file mode 100644 index 00000000..f1858e88 --- /dev/null +++ b/spec/controllers/spree/admin/taxons_controller_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' + +RSpec.describe Spree::Admin::TaxonsController, type: :controller do + stub_authorization! + render_views + + let(:store) { Spree::Store.default } + let(:taxonomy) { create(:taxonomy, store: store) } + + describe 'GET #select_options' do + before { Spree::Taxon.destroy_all } + + let!(:automatic_taxon) { create(:taxon, taxonomy: taxonomy, name: 'Automatic Taxon', automatic: true) } + let!(:manual_taxon) { create(:taxon, taxonomy: taxonomy, name: 'Manual Taxon', automatic: false) } + + context 'with a vendor user' do + let(:admin_user) { create(:vendor_user, vendor: vendor) } + let(:vendor) { create(:approved_vendor) } + + before do + allow(controller).to receive(:current_ability).and_return(Spree::Dependencies.ability_class.constantize.new(admin_user)) + allow(controller).to receive(:authorize_admin).and_return(true) + end + + it 'returns only manual taxons' do + get :select_options + + expect(JSON.parse(response.body)).to eq([{ 'id' => manual_taxon.id, 'name' => manual_taxon.pretty_name }]) + expect(assigns.keys).not_to include('object') + end + end + end +end diff --git a/spec/controllers/spree/admin/vendor_settings_controller_spec.rb b/spec/controllers/spree/admin/vendor_settings_controller_spec.rb new file mode 100644 index 00000000..4071bb99 --- /dev/null +++ b/spec/controllers/spree/admin/vendor_settings_controller_spec.rb @@ -0,0 +1,50 @@ +require 'spec_helper' + +RSpec.describe Spree::Admin::VendorSettingsController, type: :controller do + stub_authorization! + render_views + + let!(:vendor) { create(:vendor, name: 'John Doe') } + let(:admin_user) { create(:vendor_user, vendor: vendor) } + + describe 'GET #edit' do + it 'renders the edit template' do + get :edit + expect(response).to render_template(:edit) + end + + context 'when there is no current vendor' do + before do + allow(controller).to receive(:current_vendor).and_return(nil) + end + + it 'redirects to the root path' do + get :edit + expect(response).to redirect_to(spree.admin_dashboard_path) + end + end + end + + describe 'PUT #update' do + subject { put :update, params: { vendor: { name: 'Jane Moe' } } } + + it 'updates the vendor' do + subject + + expect(vendor.reload.name).to eq('Jane Moe') + expect(response).to redirect_to(spree.edit_admin_vendor_settings_path) + end + + context 'when there is no current vendor' do + before do + allow(controller).to receive(:current_vendor).and_return(nil) + end + + it 'redirects to the root path' do + subject + + expect(response).to redirect_to(spree.admin_dashboard_path) + end + end + end +end diff --git a/spec/controllers/spree/admin/vendors_controller_spec.rb b/spec/controllers/spree/admin/vendors_controller_spec.rb new file mode 100644 index 00000000..0576c9a4 --- /dev/null +++ b/spec/controllers/spree/admin/vendors_controller_spec.rb @@ -0,0 +1,165 @@ +require 'spec_helper' + +describe Spree::Admin::VendorsController, type: :controller do + stub_authorization! + render_views + + let(:store) { Spree::Store.default } + + describe '#index' do + let!(:vendor_one) { create(:approved_vendor, products_count: 1, sales_total: 10, commission_total: 100, name: 'Vendor_one') } + let!(:vendor_two) { create(:approved_vendor, products_count: 2, sales_total: 20, commission_total: 200) } + + let(:format) { 'html' } + + before do + get :index, format: format + end + + it 'renders index' do + expect(response).to have_http_status(:ok) + end + + it 'return vendors' do + expect(assigns(:collection)).to include(vendor_one) + expect(assigns(:collection)).to include(vendor_two) + end + + it 'orders by products_count ascending' do + get :index, params: { q: { s: 'products_count asc' } } + expect(assigns(:collection).to_a).to eq([vendor_one, vendor_two]) + end + + it 'orders by products_count descending' do + get :index, params: { q: { s: 'products_count desc' } } + expect(assigns(:collection).to_a).to eq([vendor_two, vendor_one]) + end + + it 'orders by sales_total ascending' do + get :index, params: { q: { s: 'sales_total asc' } } + expect(assigns(:collection).to_a).to eq([vendor_one, vendor_two]) + end + + it 'orders by sales_total descending' do + get :index, params: { q: { s: 'sales_total desc' } } + expect(assigns(:collection).to_a).to eq([vendor_two, vendor_one]) + end + + it 'orders by commission_total ascending' do + get :index, params: { q: { s: 'commission_total asc' } } + expect(assigns(:collection).to_a).to eq([vendor_one, vendor_two]) + end + + it 'orders by commission_total descending' do + get :index, params: { q: { s: 'commission_total desc' } } + expect(assigns(:collection).to_a).to eq([vendor_two, vendor_one]) + end + + it 'orders by join date ascending' do + get :index, params: { q: { s: 'created_at asc' } } + expect(assigns(:collection).to_a).to eq([vendor_one, vendor_two]) + end + + it 'orders by join date descending' do + get :index, params: { q: { s: 'created_at desc' } } + expect(assigns(:collection).to_a).to eq([vendor_two, vendor_one]) + end + + it 'should only give distinct vendor when searched with vendor name' do + get :index, params: { q: { name_eq: 'Vendor_one' } } + expect(assigns(:collection).to_a).to contain_exactly(vendor_one) + expect(assigns(:collection).to_a.count).to eq(1) + end + + it 'should only give vendors with integration as shopify' do + get :index, params: { q: { integration_eq: 'shopify' } } + expect(assigns(:collection).to_a).to contain_exactly(vendor_one, vendor_two) + expect(assigns(:collection).to_a.count).to eq(2) + end + + context 'CSV format' do + let(:format) { 'csv' } + + before do + get :index, format: format + end + + it 'renders CSV' do + expect(response).to have_http_status(:ok) + end + end + end + + describe '#update' do + subject(:update_vendor) do + post :update, params: { id: vendor.id, vendor: vendor_update_params } + end + + let!(:vendor) { create(:vendor) } + + context 'adds a returns address' do + let(:country) { Spree::Country.find_by(iso: 'US') } + let(:state) { create(:state, country: country, abbr: 'NY') } + + let(:vendor_update_params) do + { + returns_address_attributes: { + city: 'New York', + country_id: country.id, + state_id: state.id, + zipcode: '10036', + address1: '123 Main St' + } + } + end + + it 'creates a new returns address' do + expect { update_vendor }.to change { Spree::ReturnsAddress.count }.by(1) + vendor.reload + expect(vendor.returns_address.city).to eq('New York') + expect(vendor.returns_address.country_id).to eq(country.id) + expect(vendor.returns_address.state_id).to eq(state.id) + expect(vendor.returns_address.zipcode).to eq('10036') + expect(vendor.returns_address.address1).to eq('123 Main St') + end + + context 'update existing address' do + let(:returns_address) { create(:returns_address, city: 'New York', country: country, state: state) } + + before do + vendor.returns_address = returns_address + vendor.save! + end + + let(:vendor_update_params) do + { returns_address_attributes: { id: vendor.returns_address.id, city: 'New Jersey' } } + end + + it 'updates address' do + expect { update_vendor }.to change { vendor.returns_address.reload.city }.from('New York').to('New Jersey') + end + end + end + end + + describe '#show' do + let(:vendor) { create(:approved_vendor) } + let(:vendor_user) { create(:vendor_user, vendor: vendor) } + let(:store_role) { create(:role_user, resource: store, user: vendor_user) } + + before do + store_role + end + + it 'renders show' do + get :show, params: { id: vendor.id } + expect(response).to have_http_status(:ok) + end + + it 'assigns @vendor_users with roles for proper resource' do + get :show, params: { id: vendor.id } + expect(assigns(:vendor_users).first.role_users.size).to eq(1) + expect(assigns(:vendor_users).first.role_users.first).not_to eq(store_role) + end + end +end diff --git a/spec/factories/spree/vendor_factory.rb b/spec/factories/spree/vendor_factory.rb deleted file mode 100644 index 4267e611..00000000 --- a/spec/factories/spree/vendor_factory.rb +++ /dev/null @@ -1,28 +0,0 @@ -FactoryBot.define do - factory :vendor, class: Spree::Vendor do - name { FFaker::Company.name } - about_us { 'About us...' } - contact_us { 'Contact us...' } - - factory :active_vendor do - name { 'Active vendor' } - state { :active } - end - - factory :active_vendor_list do - name { "#{FFaker::Company.name} #{FFaker::Company.suffix }" } - state { :active } - end - - - factory :pending_vendor do - name { 'Pending vendor' } - state { :pending } - end - - factory :blocked_vendor do - name { 'Blocked vendor' } - state { :blocked } - end - end -end diff --git a/spec/factories/spree/vendor_image_factory.rb b/spec/factories/spree/vendor_image_factory.rb deleted file mode 100644 index dc3b5538..00000000 --- a/spec/factories/spree/vendor_image_factory.rb +++ /dev/null @@ -1,5 +0,0 @@ -FactoryBot.define do - factory :vendor_image, class: Spree::VendorImage do - attachment { Rack::Test::UploadedFile.new(SpreeMultiVendor::Engine.root.join('spec', 'fixtures', 'thinking-cat.jpg'), 'image/jpeg') } - end -end diff --git a/spec/factories/spree/vendor_user_factory.rb b/spec/factories/spree/vendor_user_factory.rb deleted file mode 100644 index 60459e51..00000000 --- a/spec/factories/spree/vendor_user_factory.rb +++ /dev/null @@ -1,6 +0,0 @@ -FactoryBot.define do - factory :vendor_user, class: Spree::Vendor do - user - vendor - end -end diff --git a/spec/features/spree/admin/orders_spec.rb b/spec/features/spree/admin/orders_spec.rb deleted file mode 100644 index 127ecb6a..00000000 --- a/spec/features/spree/admin/orders_spec.rb +++ /dev/null @@ -1,86 +0,0 @@ -require 'spec_helper' - -RSpec.feature 'Admin Orders', :js do - let(:store) { Spree::Store.default } - let(:vendor) { create(:active_vendor, name: 'Vendor') } - let(:vendor_2) { create(:active_vendor, name: 'Other vendor') } - let(:order) { create(:order, ship_address: create(:address), bill_address: create(:address), store: store) } - let(:order_2) { create(:order, store: store) } - let!(:user) { create(:user, vendors: [vendor]) } - let(:product) { create(:product_in_stock, vendor: vendor, stores:[store]) } - let(:product_2) { create(:product_in_stock, vendor: vendor_2,stores:[store]) } - - before do - create(:line_item, order: order, product: product) - create(:line_item, order: order, product: product_2) - order.create_proposed_shipments - order.update(state: :complete, completed_at: Time.current) - login_as(user, scope: :spree_user) - end - - shared_examples 'shows order data' do - it { expect(page).to have_text(order.number) } - it { expect(page).to have_content(order.display_vendor_total(vendor).to_html) } - end - - context 'index' do - background do - visit spree.admin_orders_path - end - - it_behaves_like 'shows order data' - - it 'does not show non-vendor orders' do - expect(page).not_to have_text(order_2.number) - end - end - - context 'cart' do - background do - visit spree.cart_admin_order_path(id: order.number) - end - - it_behaves_like 'shows order data' - end - - context 'customer info' do - background do - visit spree.admin_order_customer_path(order_id: order.number) - end - - it_behaves_like 'shows order data' - - it 'shows customer information' do - expect(page).to have_text(order.ship_address.full_name) - expect(page).to have_text(order.ship_address.address1) - expect(page).to have_text(order.ship_address.city) - - expect(page).to have_text(order.bill_address.full_name) - expect(page).to have_text(order.bill_address.address1) - expect(page).to have_text(order.bill_address.city) - - expect(page).not_to have_selector('input[type=text]') - end - end - - context 'edit' do - background do - visit spree.edit_admin_order_path(id: order.number) - end - - it_behaves_like 'shows order data' - - scenario 'vendor can add his product' do - select2_open label: 'Name or SKU' - select2_search product.name, from: 'Name or SKU' - select2_select product.name, from: 'Name or SKU', match: :first - expect(page).to have_text(product.name) - end - - scenario 'vendor cannot add other vendors product' do - select2_open label: 'Name or SKU' - select2_search product_2.name, from: 'Name or SKU' - expect(page).not_to have_text(product_2.name) - end - end -end diff --git a/spec/features/spree/admin/product_spec.rb b/spec/features/spree/admin/product_spec.rb deleted file mode 100644 index b5248086..00000000 --- a/spec/features/spree/admin/product_spec.rb +++ /dev/null @@ -1,177 +0,0 @@ -require 'spec_helper' - -RSpec.feature 'Admin Products', :js do - let(:vendor) { create(:active_vendor) } - let!(:user) { create(:user, vendors: [vendor]) } - let!(:admin) { create(:admin_user) } - let!(:option_type) { create(:option_type, name: 'Testing option') } - let!(:option_value) { create(:option_value, option_type: option_type) } - let!(:product) { create(:product, sku: 'Test1') } - let!(:vendor_product) { create(:product, vendor: vendor, sku: 'Test2', option_types: [option_type]) } - - context 'for user with admin role' do - before { login_as(admin, scope: :spree_user) } - - context 'index' do - scenario 'displays all products' do - visit spree.admin_products_path - expect(page).to have_selector('tr', count: 3) - end - end - - context 'create product' do - scenario 'creates new product with vendor id unassigned' do - visit spree.admin_products_path - click_link 'New Product' - expect(current_path).to eq spree.new_admin_product_path - - fill_in 'product_name', with: 'Vendor product' - fill_in 'product_price', with: 15 - select2 Spree::ShippingCategory.last.name, from: 'Shipping Categories' - - click_button 'Create' - - expect(page).to have_text 'successfully created!' - expect(current_path).to eq spree.edit_admin_product_path(Spree::Product.last) - expect(Spree::Product.last.vendor_id).to eq nil - end - scenario 'creates new product with assigned vendor' do - visit spree.admin_products_path - click_link 'New Product' - expect(current_path).to eq spree.new_admin_product_path - - fill_in 'product_name', with: 'Vendor product' - fill_in 'product_price', with: 15 - select2 Spree::ShippingCategory.last.name, from: 'Shipping Categories' - select2 'Active vendor', from: 'Vendor' - - click_button 'Create' - - expect(page).to have_text 'successfully created!' - expect(current_path).to eq spree.edit_admin_product_path(Spree::Product.last) - expect(Spree::Product.last.vendor_id).to eq Spree::Vendor.last.id - end - end - - context 'edit product' do - before(:each) do - visit spree.edit_admin_product_path(product) - expect(current_path).to eq spree.edit_admin_product_path(product) - end - - scenario 'can update an existing product' do - fill_in 'product_name', with: 'Testing edit' - click_button 'Update' - expect(page).to have_text 'successfully updated!' - expect(page).to have_text 'Testing edit' - end - - scenario 'can update product master price' do - fill_in 'product_price', with: 123 - click_button 'Update' - expect(page).to have_text 'successfully updated!' - product.reload - expect(product.price).to eq 123 - end - - scenario 'can update product vendor' do - expect(product.vendor).to eq nil - select2 vendor.name, from: 'Vendor' - click_button 'Update' - expect(page).to have_text 'successfully updated!' - product.reload - expect(product.vendor).to eq vendor - end - end - - context 'create variant' do - scenario 'creates new variant with vendor id assigned' do - visit spree.admin_product_variants_path(vendor_product) - click_link 'New Variant' - select2 'S', from: 'Size' - click_button 'Create' - expect(page).to have_text 'successfully created!' - expect(Spree::Variant.last.vendor_id).to eq vendor.id - end - end - end - - context 'for user with vendor' do - before(:each) do - login_as(user, scope: :spree_user) - visit spree.admin_products_path - end - - context 'index' do - scenario 'displays only vendor product' do - expect(page).to have_selector('tr', count: 2) - end - end - - context 'create product' do - scenario 'can create a new product' do - click_link 'New Product' - expect(current_path).to eq spree.new_admin_product_path - - fill_in 'product_name', with: 'Vendor product' - fill_in 'product_price', with: 15 - select2 Spree::ShippingCategory.last.name, from: 'Shipping Categories' - - click_button 'Create' - - expect(page).to have_text 'successfully created!' - expect(current_path).to eq spree.edit_admin_product_path(Spree::Product.last) - expect(Spree::Product.last.vendor_id).to eq vendor.id - end - end - - context 'edit product' do - before(:each) do - within_row(1) { click_icon :edit } - expect(current_path).to eq spree.edit_admin_product_path(vendor_product) - end - - scenario 'can update an existing product' do - fill_in 'product_name', with: 'Testing edit' - click_button 'Update' - expect(page).to have_text 'successfully updated!' - expect(page).to have_text 'Testing edit' - end - - scenario 'can update product master price' do - fill_in 'product_price', with: 123 - click_button 'Update' - expect(page).to have_text 'successfully updated!' - vendor_product.reload - expect(vendor_product.price).to eq 123 - end - - scenario 'shows validation error with blank name' do - fill_in 'product_name', with: '' - click_button 'Update' - expect(page).to have_text 'Name can\'t be blank' - end - end - - context 'create product property' do - scenario 'can create new product property' do - visit spree.admin_product_product_properties_path(vendor_product) - fill_in 'product[product_properties_attributes][0][property_name]', with: 'Testing edit' - click_button 'Update' - expect(page).to have_text 'successfully updated!' - # expect(Spree::ProductProperty.last.property.vendor_id).to eq vendor.id - end - end - - context 'create variant' do - scenario 'can create new variant' do - visit spree.admin_product_variants_path(vendor_product) - click_link 'New Variant' - select2 'S', from: 'Size' - click_button 'Create' - expect(page).to have_text 'successfully created!' - expect(Spree::Variant.last.option_values).to include option_value - end - end - end -end diff --git a/spec/features/spree/admin/shipping_method_spec.rb b/spec/features/spree/admin/shipping_method_spec.rb deleted file mode 100644 index ce54f3c0..00000000 --- a/spec/features/spree/admin/shipping_method_spec.rb +++ /dev/null @@ -1,72 +0,0 @@ -require 'spec_helper' - -RSpec.feature 'Admin Shipping Methods', :js do - let(:active_vendor) { create(:active_vendor) } - let!(:user) { create(:user, vendors: [active_vendor]) } - let!(:admin) { create(:admin_user) } - let!(:shipping_method) { create(:shipping_method, name: 'Test') } - let!(:vendor_shipping_method) { create(:shipping_method, name: 'Test', vendor: active_vendor) } - - context 'for user with admin role' do - context 'index' do - scenario 'displays all shipping methods' do - login_as(admin, scope: :spree_user) - visit spree.admin_shipping_methods_path - expect(page).to have_selector('tr', count: 3) - end - end - end - - context 'for user with vendor' do - before(:each) do - login_as(user, scope: :spree_user) - visit spree.admin_shipping_methods_path - end - - context 'index' do - scenario 'displays only vendor shipping method' do - expect(page).to have_selector('tr', count: 2) - end - end - - context 'create' do - scenario 'can create a new shipping method' do - click_link 'New Shipping Method' - expect(current_path).to eq spree.new_admin_shipping_method_path - - fill_in 'shipping_method_name', with: 'Vendor shipping method' - check Spree::ShippingCategory.last.name - - if Spree.version.to_f >= 4.0 - select2 'Both', from: 'Display' - end - - click_button 'Create' - - expect(page).to have_text 'successfully created!' - expect(current_path).to eq spree.edit_admin_shipping_method_path(Spree::ShippingMethod.last) - expect(Spree::ShippingMethod.last.vendor_id).to eq active_vendor.id - end - end - - context 'edit' do - before(:each) do - within_row(1) { click_icon :edit } - expect(current_path).to eq spree.edit_admin_shipping_method_path(active_vendor.shipping_methods.first) - end - - scenario 'can update an existing shipping method' do - fill_in 'shipping_method_name', with: 'Testing edit' - click_button 'Update' - expect(page).to have_text 'successfully updated!' - expect(page).to have_text 'Testing edit' - end - - scenario 'shows validation error with blank name' do - fill_in 'shipping_method_name', with: '' - click_button 'Update' - expect(page).to_not have_text 'successfully created!' - end - end - end -end diff --git a/spec/features/spree/admin/stock_location_spec.rb b/spec/features/spree/admin/stock_location_spec.rb deleted file mode 100644 index 5d00c6a9..00000000 --- a/spec/features/spree/admin/stock_location_spec.rb +++ /dev/null @@ -1,90 +0,0 @@ -require 'spec_helper' - -RSpec.feature 'Admin Stock Locations', :js do - let(:active_vendor) { create(:active_vendor) } - let!(:product) { create(:product, vendor_id: active_vendor.id, name: 'Test') } - let!(:user) { create(:user, vendors: [active_vendor]) } - let!(:admin) { create(:admin_user) } - let!(:stock_location) { create(:stock_location, name: 'Test') } - - context 'for user with admin role' do - context 'index' do - scenario 'displays all stock locations' do - login_as(admin, scope: :spree_user) - visit spree.admin_stock_locations_path - expect(page).to have_selector('tr', count: 3) - end - end - end - - context 'for user with vendor' do - before(:each) do - login_as(user, scope: :spree_user) - visit spree.admin_stock_locations_path - end - - context 'index' do - scenario 'displays only vendor stock location' do - expect(page).to have_selector('tr', count: 2) - end - end - - context 'stock movements' do - scenario 'displays stock movements for vendor stock location' do - click_on 'Stock Movements' - expect(page).to have_text 'Stock Movements for Active vendor' - end - - scenario 'can create a new stock movement for vendor stock location' do - click_on 'Stock Movements' - click_on 'New Stock Movement' - expect(current_path).to eq spree.new_admin_stock_location_stock_movement_path(active_vendor.stock_locations.first) - - fill_in 'stock_movement_quantity', with: 5 - select2_open label: 'Stock Item' - select2_search product.name, from: 'Stock Item' - select2_select product.name, from: 'Stock Item', match: :first - - click_button 'Create' - - expect(page).to have_text 'successfully created!' - expect(Spree::StockMovement.count).to eq 1 - end - end - - context 'create' do - scenario 'can create a new stock location' do - click_link 'New Stock Location' - expect(current_path).to eq spree.new_admin_stock_location_path - - fill_in 'stock_location_name', with: 'Vendor stock location' - - click_button 'Create' - - expect(page).to have_text 'successfully created!' - expect(current_path).to eq spree.admin_stock_locations_path - expect(Spree::StockLocation.last.vendor_id).to eq active_vendor.id - end - end - - context 'edit' do - before(:each) do - within_row(1) { click_icon :edit } - expect(current_path).to eq spree.edit_admin_stock_location_path(active_vendor.stock_locations.first) - end - - scenario 'can update an existing stock location' do - fill_in 'stock_location_name', with: 'Testing edit' - click_button 'Update' - expect(page).to have_text 'successfully updated!' - expect(page).to have_text 'Testing edit' - end - - scenario 'shows validation error with blank name' do - fill_in 'stock_location_name', with: '' - click_button 'Update' - expect(page).to_not have_text 'successfully created!' - end - end - end -end diff --git a/spec/features/spree/admin/vendor_settings_spec.rb b/spec/features/spree/admin/vendor_settings_spec.rb deleted file mode 100644 index 067f3e19..00000000 --- a/spec/features/spree/admin/vendor_settings_spec.rb +++ /dev/null @@ -1,74 +0,0 @@ -require 'spec_helper' - -RSpec.feature 'Admin Vendor Settings', :js do - let(:vendor) { create(:active_vendor) } - let(:blocked_vendor) { create(:blocked_vendor)} - let!(:user) { create(:user, vendors: [vendor]) } - let!(:user_with_blocked_vendor) { create(:user, vendors: [blocked_vendor]) } - - context 'edit' do - background do - login_as(user, scope: :spree_user) - visit spree.admin_vendor_settings_path - end - - scenario 'can update current vendor' do - fill_in 'vendor_name', with: 'Testing edit' - fill_in 'vendor_about_us', with: 'Testing about us edit' - fill_in 'vendor_contact_us', with: 'Testing contact us edit' - expect { - click_button 'Update' - }.to change { vendor.reload.slug }.from('active-vendor').to('testing-edit') - expect(page).to have_text 'Testing edit' - expect(page).to have_text 'Testing about us edit' - expect(page).to have_text 'Testing contact us edit' - end - - scenario 'shows validation error with blank name' do - fill_in 'vendor_name', with: '' - click_button 'Update' - expect(page).to have_text 'name can\'t be blank' - end - - scenario 'does not show validation error with blank about_us' do - fill_in 'vendor_about_us', with: '' - click_button 'Update' - expect(page).not_to have_text 'about_us can\'t be blank' - end - - scenario 'does not show validation error with blank contact_us' do - fill_in 'vendor_contact_us', with: '' - click_button 'Update' - expect(page).not_to have_text 'contact_us can\'t be blank' - end - - scenario 'can update an existing vendor image' do - create(:vendor, name: 'New vendor') - page.attach_file("vendor_image", Spree::Core::Engine.root + 'spec/fixtures' + 'thinking-cat.jpg') - click_button 'Update' - expect(page).to have_css("img[src*='thinking-cat.jpg']") - end - end - - context 'no vendor present' do - before do - vendor.destroy! - end - - it 'renders page without errors' do - visit spree.admin_vendor_settings_path - expect(page).to have_current_path('/admin/login') - end - end - - context 'blocked vendor present' do - background do - login_as(user_with_blocked_vendor, scope: :spree_user) - end - - it 'renders forbidden page' do - visit spree.admin_vendor_settings_path - expect(current_path).to eq spree.admin_forbidden_path - end - end -end diff --git a/spec/features/spree/admin/vendor_spec.rb b/spec/features/spree/admin/vendor_spec.rb deleted file mode 100644 index 241ede16..00000000 --- a/spec/features/spree/admin/vendor_spec.rb +++ /dev/null @@ -1,125 +0,0 @@ -require 'spec_helper' - -RSpec.feature 'Admin Vendors', :js do - let!(:admin) { create(:admin_user) } - let!(:vendor_user) { create(:user) } - - background do - login_as(admin, scope: :spree_user) - create(:active_vendor, users: [vendor_user]) if Spree::Vendor.find_by(name: 'Active vendor').nil? - create(:pending_vendor) - create(:blocked_vendor) - visit spree.admin_vendors_path - end - - context 'index' do - scenario 'displays existing vendors' do - within_row(1) do - expect(column_text(1)).to eq vendor_user.email - expect(column_text(2)).to eq 'Active vendor' - expect(column_text(3)).to eq 'active' - end - end - - scenario 'filter by multiple states' do - click_button 'Filter' - select2 'Active', from: 'State' - select2 'Pending', from: 'State' - - click_button 'Filter Results' - - expect(page).to have_content('active') - expect(page).to have_content('pending') - expect(page).not_to have_content('blocked') - end - end - - context 'create' do - scenario 'can create a new vendor' do - click_link 'New Vendor' - expect(current_path).to eq spree.new_admin_vendor_path - - fill_in 'vendor_name', with: 'Test' - fill_in 'vendor_about_us', with: 'About...' - fill_in 'vendor_contact_us', with: 'Contact...' - expect(find_field('vendor_commission_rate').value).to eq '5.0' - select 'Blocked' - - click_button 'Create' - - expect(page).to have_text 'successfully created!' - expect(current_path).to eq spree.admin_vendors_path - end - - scenario 'shows validation error with blank name' do - click_link 'New Vendor' - expect(current_path).to eq spree.new_admin_vendor_path - - fill_in 'vendor_name', with: '' - click_button 'Create' - - expect(page).to have_text 'name can\'t be blank' - end - - scenario 'shows validation error with repeated name' do - click_link 'New Vendor' - expect(current_path).to eq spree.new_admin_vendor_path - - fill_in 'vendor_name', with: 'Active vendor' - click_button 'Create' - - expect(page).to have_text 'name has already been taken' - end - end - - context 'edit' do - background do - within_row(1) { click_icon :edit } - expect(current_path).to eq spree.edit_admin_vendor_path('active-vendor') - end - - scenario 'can update an existing vendor' do - fill_in 'vendor_name', with: 'Testing edit' - fill_in 'vendor_about_us', with: 'Testing about us' - fill_in 'vendor_contact_us', with: 'Testing contact us' - fill_in 'vendor_commission_rate', with: '5.5' - click_button 'Update' - expect(page).to have_text 'successfully updated!' - expect(page).to have_text 'Testing edit' - end - - if Spree.version.to_f >= 3.6 - scenario 'can update an existing vendor image' do - page.attach_file("vendor_image", Spree::Core::Engine.root + 'spec/fixtures' + 'thinking-cat.jpg') - expect { click_button 'Update' }.to change(Spree::VendorImage, :count).by(1) - expect(page).to have_text 'successfully updated!' - end - end - - scenario 'shows validation error with blank name' do - fill_in 'vendor_name', with: '' - click_button 'Update' - expect(page).to have_text 'name can\'t be blank' - end - - scenario 'does not show validation error with blank about_us' do - fill_in 'vendor_about_us', with: '' - click_button 'Update' - expect(page).not_to have_text 'about_us can\'t be blank' - end - - scenario 'does not show validation error with blank contact_us' do - fill_in 'vendor_contact_us', with: '' - click_button 'Update' - expect(page).not_to have_text 'contact_us can\'t be blank' - end - - scenario 'shows validation error with repeated name' do - create(:vendor, name: 'New vendor') - - fill_in 'vendor_name', with: 'New vendor' - click_button 'Update' - expect(page).to have_text 'name has already been taken' - end - end -end diff --git a/spec/fixtures/files/icon_256x256.jpg b/spec/fixtures/files/icon_256x256.jpg new file mode 100644 index 00000000..2edee4a6 Binary files /dev/null and b/spec/fixtures/files/icon_256x256.jpg differ diff --git a/spec/fixtures/thinking-cat.jpg b/spec/fixtures/thinking-cat.jpg deleted file mode 100644 index 7e8524d3..00000000 Binary files a/spec/fixtures/thinking-cat.jpg and /dev/null differ diff --git a/spec/i18n_spec.rb b/spec/i18n_spec.rb new file mode 100644 index 00000000..eacd60d0 --- /dev/null +++ b/spec/i18n_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' +require 'i18n/tasks' + +RSpec.describe I18n do + let(:i18n) { I18n::Tasks::BaseTask.new } + let(:missing_keys) { i18n.missing_keys } + let(:inconsistent_interpolations) { i18n.inconsistent_interpolations } + + it 'does not have missing keys' do + expect(missing_keys).to be_empty, + "Missing #{missing_keys.leaves.count} i18n keys, run `i18n-tasks missing' to show them" + end + + it 'files are normalized' do + non_normalized = i18n.non_normalized_paths + error_message = "The following files need to be normalized:\n" \ + "#{non_normalized.map { |path| " #{path}" }.join("\n")}\n" \ + "Please run `i18n-tasks normalize' to fix" + expect(non_normalized).to be_empty, error_message + end + + it 'does not have inconsistent interpolations' do + error_message = "#{inconsistent_interpolations.leaves.count} i18n keys have inconsistent interpolations.\n" \ + "Run `i18n-tasks check-consistent-interpolations' to show them" + expect(inconsistent_interpolations).to be_empty, error_message + end +end diff --git a/spec/mailers/vendor_mailer_spec.rb b/spec/mailers/vendor_mailer_spec.rb index 537f513c..8002d62a 100644 --- a/spec/mailers/vendor_mailer_spec.rb +++ b/spec/mailers/vendor_mailer_spec.rb @@ -1,45 +1,131 @@ require 'spec_helper' +require 'email_spec' -describe Spree::VendorMailer, type: :mailer do - let(:store) { create(:store)} - let(:vendor) { Spree::Vendor.new(id: 1, name: 'Test vendor', state: 'active', notification_email: 'test-vendor@example.com') } - let(:other_vendor) { Spree::Vendor.new(id: 2, name: 'Other vendor', state: 'active', notification_email: 'other-vendor@example.com') } - let(:order) { create(:completed_order_with_totals, line_items_count: 5) } +RSpec.describe Spree::VendorMailer, type: :mailer do + include EmailSpec::Helpers + include EmailSpec::Matchers - context 'vendor notification email' do - before do - order.line_items.first.product.update(vendor: vendor) - order.line_items.last.product.update(vendor: other_vendor) + let!(:store) { Spree::Store.default } + let!(:marketplace_owner) { create(:admin_user) } + let!(:vendor) { create(:vendor, invited_by: marketplace_owner) } + let!(:vendor_owner) { create(:vendor_user, vendor: vendor) } + + describe '#vendor_suspended' do + let(:vendor_suspended_mail) { described_class.vendor_suspended(vendor) } + + it 'has Vendor Owner email as to mail' do + expect(vendor_suspended_mail.to).to include(vendor_owner.email) + end + + it 'contains Store name and Vendor name in subject' do + expect(vendor_suspended_mail.subject).to include(store.name) + expect(vendor_suspended_mail.subject).to include(vendor.name) end - it 'is sent from current store email address' do - notification_email = Spree::VendorMailer.vendor_notification_email(order.id, vendor.id) - expect(notification_email.from).to have_content(Spree::Store.current.mail_from_address) + it 'has the correct message in the body' do + message = 'If you would like to discuss this matter please contact' + \ + " us via email #{store.mail_from_address}" + + expect(vendor_suspended_mail).to have_body_text("We're sorry but your organization was suspended.") + expect(vendor_suspended_mail).to have_body_text(message) + end + end + + describe '#vendor_approved' do + describe 'email sent to vendor owners' do + subject(:vendor_approved_mail) { described_class.with(vendor: vendor, recipient: vendor_owner).vendor_approved } + + it 'has Vendor Owner email as to mail' do + expect(vendor_approved_mail.to).to include(vendor_owner.email) + end + + it 'contains Store name and Vendor name in subject' do + expect(vendor_approved_mail.subject).to include(store.name) + expect(vendor_approved_mail.subject).to include(vendor.name) + end + + it 'has the correct message in the body' do + expect(vendor_approved_mail).to have_body_text(spree.admin_dashboard_path) + end end - it 'is sent to vendors email address' do - notification_email = Spree::VendorMailer.vendor_notification_email(order.id, vendor.id) - expect(notification_email.to).to have_content(vendor.contact_us) + describe 'email sent to marketplace owners' do + subject(:vendor_approved_mail) do + described_class.with( + vendor: vendor, + recipient: marketplace_owner + ).vendor_approved + end + + it 'has marketplace owner email as to mail' do + expect(vendor_approved_mail.to).to include(marketplace_owner.email) + end + + it 'has valid subject' do + expect(vendor_approved_mail.subject).to eq "#{vendor.name} has been approved to start selling on #{vendor.store.name}!" + end + + it 'has the correct message in the body' do + expect(vendor_approved_mail).to have_body_text "#{vendor.name} has been approved to start selling on #{vendor.store.name}!" + expect(vendor_approved_mail).to have_body_text(spree.admin_vendor_url(vendor, host: vendor.store.url)) + end end + end + + describe '#vendor_rejected' do + let(:vendor_rejected_mail) { described_class.vendor_rejected(vendor) } - it 'subject contains order number' do - notification_email = Spree::VendorMailer.vendor_notification_email(order.id, vendor.id) - expect(notification_email.subject).to have_content(order.number) + it 'has Vendor Owner email as to mail' do + expect(vendor_rejected_mail.to).to include(vendor_owner.email) end - it 'body contains vendors items' do - notification_email = Spree::VendorMailer.vendor_notification_email(order.id, vendor.id) - vendors_item = order.line_items.select { |line_item| line_item.product.vendor == vendor }.first - expect(notification_email.body.parts.first).to have_text(vendors_item.product.name) - expect(notification_email.body.parts.last).to have_text(vendors_item.product.name) + it 'contains Store name and Vendor name in subject' do + expect(vendor_rejected_mail.subject).to include(store.name) + expect(vendor_rejected_mail.subject).to include(vendor.name) end - it 'body does not contain other vendors items' do - notification_email = Spree::VendorMailer.vendor_notification_email(order.id, vendor.id) - other_vendors_item = order.line_items.select { |line_item| line_item.product.vendor != vendor }.first - expect(notification_email.body.parts.first).not_to have_text(other_vendors_item.product.name) - expect(notification_email.body.parts.last).not_to have_text(other_vendors_item.product.name) + it 'has the correct message in the body' do + expect(vendor_rejected_mail).to have_body_text(store.mail_from_address) end end + describe '#vendor_onboarding_started' do + let(:vendor_onboarding_started_mail) { described_class.with(vendor: vendor, recipient: marketplace_owner).vendor_onboarding_started } + + context 'to mail' do + it 'has a Store Owners emails as to mail' do + expect(vendor_onboarding_started_mail.to).to eq([marketplace_owner.email]) + end + end + + it 'contains Vendor name and link to Vendor profile in subject' do + expect(vendor_onboarding_started_mail.subject).to include(vendor.name) + expect(vendor_onboarding_started_mail.subject).to include('started the onboarding process') + end + + it 'has the correct message in the body' do + expect(vendor_onboarding_started_mail).to have_body_text(vendor.name) + expect(vendor_onboarding_started_mail).to have_body_text(spree.admin_vendor_url(vendor, host: vendor.store.url)) + end + end + + describe '#vendor_onboarding_completed' do + let(:vendor_onboarding_completed_mail) { described_class.with(vendor: vendor, recipient: marketplace_owner).vendor_onboarding_completed } + + context 'to mail' do + it 'has a Store Owners emails as to mail' do + expect(vendor_onboarding_completed_mail.to).to eq([marketplace_owner.email]) + end + end + + it 'contains Vendor name and link to Vendor profile in subject' do + expect(vendor_onboarding_completed_mail).to have_subject(/#{vendor.name}/) + expect(vendor_onboarding_completed_mail).to have_subject(/completed their onboarding/) + end + + it 'has the correct message in the body' do + expect(vendor_onboarding_completed_mail).to have_body_text(vendor.name) + expect(vendor_onboarding_completed_mail).to have_body_text(spree.admin_vendor_url(vendor, host: vendor.store.url)) + end + end end diff --git a/spec/models/spree/calculators/returns/platform_refund_amount_spec.rb b/spec/models/spree/calculators/returns/platform_refund_amount_spec.rb new file mode 100644 index 00000000..9b219519 --- /dev/null +++ b/spec/models/spree/calculators/returns/platform_refund_amount_spec.rb @@ -0,0 +1,109 @@ +require 'spec_helper' + +RSpec.describe Spree::Calculators::Returns::PlatformRefundAmount do + let(:calculator) { described_class.new } + + describe '#compute' do + subject { calculator.compute(return_item) } + + let(:return_item) { create(:return_item, inventory_unit: inventory_unit) } + let(:inventory_unit) { create(:inventory_unit, quantity: return_quantity, line_item: line_item) } + + let(:line_item) do + create(:line_item, + quantity: 3, + price: 100, + platform_fee_per_unit: 10.12) + end + + let(:exchange_variant) { nil } + let(:return_quantity) { 3 } + let(:promo_total) { 0 } + let(:included_tax_total) { 0 } + + before do + line_item.update!( + promo_total: promo_total, + included_tax_total: included_tax_total + ) + end + + context 'for a full refund' do + it { is_expected.to eq(269.64) } + + context 'when discounted' do + let(:promo_total) { 9.24 } + + it { is_expected.to eq(260.4) } + end + + context 'when there is included tax' do + let(:included_tax_total) { 9.24 } + + it { is_expected.to eq(260.4) } + end + + context 'when both discounted and there is included tax' do + let(:promo_total) { 9.24 } + let(:included_tax_total) { 9.24 } + + it { is_expected.to eq(251.16) } + end + + context 'when there is no platform fee' do + before do + line_item.update!( + platform_fee_per_unit: 0.00, + platform_fee_total: 0.00, + platform_fee_rate: 0.00 + ) + end + + it { is_expected.to eq(300.00) } + end + end + + context 'for a partial refund' do + let(:return_quantity) { 2 } + + it { is_expected.to eq(179.76) } + + context 'when discounted' do + let(:promo_total) { 9.24 } + + it { is_expected.to eq(173.6) } + end + + context 'when there is included tax' do + let(:included_tax_total) { 9.24 } + + it { is_expected.to eq(173.6) } + end + + context 'when both discounted and there is included tax' do + let(:promo_total) { 9.24 } + let(:included_tax_total) { 9.24 } + + it { is_expected.to eq(167.44) } + end + + context 'when there is no platform fee' do + before do + line_item.update!( + platform_fee_per_unit: 0.00, + platform_fee_total: 0.00, + platform_fee_rate: 0.00 + ) + end + + it { is_expected.to eq(200.00) } + end + end + + context 'when return is an exchange' do + let(:return_item) { double(:return_item, exchange_requested?: true) } + + it { is_expected.to eq(0) } + end + end +end diff --git a/spec/models/spree/invitation_spec.rb b/spec/models/spree/invitation_spec.rb new file mode 100644 index 00000000..2653056d --- /dev/null +++ b/spec/models/spree/invitation_spec.rb @@ -0,0 +1,30 @@ +require 'spec_helper' + +RSpec.describe Spree::Invitation, type: :model do + let!(:store) { create(:store, default: true) } + let(:invitation) { create(:invitation) } + + before do + clear_enqueued_jobs + end + + describe 'State Machine' do + it 'has initial state of pending' do + expect(invitation.status).to eq('pending') + end + + context 'when accepting an invitation' do + before do + invitation.invitee = create(:admin_user, :without_admin_role) + end + + context 'when the resource is a vendor' do + it 'starts onboarding' do + invitation.resource = create(:invited_vendor) + invitation.accept! + expect(invitation.resource.reload.onboarding?).to be true + end + end + end + end +end diff --git a/spec/models/spree/line_item_spec.rb b/spec/models/spree/line_item_spec.rb index fa6e7273..6eb8d316 100644 --- a/spec/models/spree/line_item_spec.rb +++ b/spec/models/spree/line_item_spec.rb @@ -1,32 +1,50 @@ require 'spec_helper' -describe Spree::LineItem do - let(:vendor) { create(:vendor) } - - describe '#vendor' do - context 'vendorized product' do - let(:product) { create(:product, vendor: vendor) } - let(:line_item) { build(:line_item, product: product, variant: product.default_variant) } - - it { expect(line_item.vendor).not_to be_nil } - it { expect(line_item.vendor).to eq(product.vendor) } +RSpec.describe Spree::LineItem, type: :model do + context 'vendorized line item' do + it 'assigns vendor from product' do + product = create(:product_in_stock, vendor: create(:approved_vendor)) + line_item = create(:line_item, product: product) + expect(line_item.vendor).to eq(product.vendor) end + end - context 'vendorized variant' do - let(:product) { create(:product) } - let(:variant) { create(:variant, vendor: vendor, product: product) } - let(:line_item) { build(:line_item, product: product, variant: variant) } + context 'scope' do + context 'platform_feeable' do + subject { described_class.platform_feeable } + + let(:product) { create(:product_in_stock, vendor: vendor) } + let(:vendor) { create(:approved_vendor) } + let(:line_item) { create(:line_item, product: product) } before do - SpreeMultiVendor::Config[:vendorized_models] = ['variant'] + line_item.update_column(:pre_tax_amount, pre_tax_amount) + end + + context 'if item belongs to the vendor and has positive pre_tax_amount' do + let(:pre_tax_amount) { 1.0 } + + it 'returns an item' do + expect(subject).to eq [line_item] + end end - after do - SpreeMultiVendor::Config[:vendorized_models] = SpreeMultiVendor::Configuration::DEFAULT_VENDORIZED_MODELS + context 'if item belongs to the vendor, but has zero pretax_amount' do + let(:pre_tax_amount) { 0.0 } + + it 'does not return the item' do + expect(subject).to be_empty + end end - it { expect(line_item.vendor).not_to be_nil } - it { expect(line_item.vendor).to eq(variant.vendor) } + context 'if does not belong to the vendor, but has positive pretax_amount' do + let(:pre_tax_amount) { 1.0 } + let(:vendor) { nil } + + it 'does not return the item' do + expect(subject).to be_empty + end + end end end end diff --git a/spec/models/spree/order_commission_spec.rb b/spec/models/spree/order_commission_spec.rb deleted file mode 100644 index dce1bc8f..00000000 --- a/spec/models/spree/order_commission_spec.rb +++ /dev/null @@ -1,8 +0,0 @@ -require 'spec_helper' - -describe Spree::OrderCommission do - describe 'associations' do - it { is_expected.to belong_to(:order) } - it { is_expected.to belong_to(:vendor) } - end -end diff --git a/spec/models/spree/order_spec.rb b/spec/models/spree/order_spec.rb index 7e61ae7f..dfd70042 100644 --- a/spec/models/spree/order_spec.rb +++ b/spec/models/spree/order_spec.rb @@ -1,193 +1,505 @@ require 'spec_helper' -describe Spree::Order do - let(:vendor) { create(:vendor, name: 'Vendor 1') } - let(:vendor_2) { create(:vendor, name: 'Vendor 2') } - - let(:order) { create(:order_with_line_items, state: 'payment', line_items_count: 6, line_items_price: 100, shipment_cost: 50) } +class FakeCalculator < Spree::Calculator + def compute(_computable) + 5 + end +end +RSpec.describe Spree::Order, type: :model do before do - order.line_items.each_with_index do |li, idx| - product = li.product - product.vendor = idx.odd? ? vendor : vendor_2 - product.save - end + allow(Spree::LegacyUser).to receive_messages(current: create(:user)) end - context 'associations' do - it { is_expected.to have_many(:commissions) } + let(:user) { create(:user) } + let(:store) { Spree::Store.default } + let(:order) { create(:order, user: user, store: store) } + + describe 'vendors association' do + let(:vendor) { create(:vendor) } + let(:product) { create(:product_in_stock, vendor: vendor, price: 100.0) } + let(:variant) { product.default_variant } + let!(:line_item) { create_list(:line_item, 2, order: order, product: product, price: product.price) } + + it do + # Regression test for ActiveRecord::StatementInvalid: PG::InvalidColumnReference: ERROR: for SELECT DISTINCT, ORDER BY expressions must appear in select list + expect { order.vendors }.not_to raise_error(ActiveRecord::StatementInvalid) + expect(order.vendors.to_a).to match_array([vendor]) + end end - context 'complete with spree_multi_vendor' do - before { allow(order).to receive_messages payment_required?: false } + describe 'callbacks' do + describe 'after_complete' do + let(:order) { create(:order_with_totals, state: :confirm, total: 100) } - it 'generates commission records' do - expect do - order.next! - order.reload - end.to change { Spree::OrderCommission.count }.by(2).and change { vendor.commissions.count }.by(1) - .and change { vendor_2.commissions.count }.by(1) + describe '#split_order' do + subject(:complete_order) { order.next! } + + before do + allow(order).to receive(:process_payments!) + end + + context 'for an order with 1st party products' do + let(:order) { create(:placed_order) } + + it 'skips splitting the order after completing it' do + expect { complete_order }.to_not have_enqueued_job(SpreeMultiVendor::Orders::SplitByVendorJob) + end + end + + context 'for an order with 3rd party products' do + let(:order) { create(:placed_order_with_vendor_items) } + + it 'splits order after completing it' do + expect { complete_order }.to have_enqueued_job(SpreeMultiVendor::Orders::SplitByVendorJob).with(order.id) + end + end + end + end - order.line_items.includes(product: :vendor).group_by { |li| li.product.vendor }.each do |v, line_items| - commission_ammount = v.commission_rate * line_items.pluck(:pre_tax_amount).sum.to_f / 100 + describe 'touches parent order on update' do + let!(:parent_order) { create(:splitted_order) } + let!(:order) { create(:vendor_order_ready_to_ship, parent: parent_order) } - expect(v.commissions.sum(:amount)).to eq(commission_ammount) + it 'touches parent order' do + expect { order.touch }.to change { parent_order.reload.updated_at } end end + end + + describe '#cancel' do + # this test should be improved with real data and VCRs + context 'when splitted' do + let(:payment_method) { create(:credit_card_payment_method, stores: [store]) } + + let!(:parent_order) { create(:splitted_order, shipment_cost: 5) } + # setting shipment_cost to 0 to avoid shipment cost calculation and for easier checking if refunds amounts are correct as we do + # not refund shipment cost + let!(:order) { create(:vendor_order_ready_to_ship, with_payment: false, store: store, parent: parent_order, shipment_cost: 0, total: 100, payment_total: 0) } + + let(:payment) do + create( + :payment, + order: order, + amount: 100, + state: 'completed', + payment_method: payment_method + ) + end + + before do + # Just to speed up the test, we don't care about the email anyway + allow(Spree::OrderMailer).to receive(:cancel_email).and_return(double(deliver_later: true)) + order.update_column(:total, 110) + payment + end + + context 'single sub-order without 1st party products' do + before do + parent_order.line_items.destroy_all + + order.cancel + parent_order.reload + order.reload + end + + it 'marks the splitted payments as void' do + expect(order.payments.first).to be_void + end + + it 'marks the parent order and sub-order both as canceled' do + expect(parent_order.reload).to be_canceled + expect(parent_order.payment_state).to eq 'void' + expect(parent_order.shipment_state).to eq 'canceled' + expect(order).to be_canceled + end + + it 'marks the splitted order payment status as failed' do + expect(order.payment_state).to eq 'failed' + end + end + + context 'refunds' do + it 'creates a refund for the sub-order' do + expect { order.cancel }.to change { Spree::Refund.count }.by(2) - it 'sends mails only once per vendor' do - mail_double_1 = double(:mail_double_1) - mail_double_2 = double(:mail_double_1) + expect(order.reload.refunds.count).to eq 1 + expect(order.refunds.first.amount).to eq 100 + expect(order.refunds.first.payment).to eq order.payments.first - expect(Spree::VendorMailer).to receive(:vendor_notification_email) - .with(be_an(Integer), vendor.id) { mail_double_1 } - expect(mail_double_1).to receive(:deliver_later) - expect(Spree::VendorMailer).to receive(:vendor_notification_email) - .with(be_an(Integer), vendor_2.id) { mail_double_2 } - expect(mail_double_2).to receive(:deliver_later) + expect(parent_order.reload.refunds.count).to eq 1 + expect(parent_order.refunds.first.amount).to eq(order.total + order.platform_fee_total.abs) + end - order.next! + it 'creates a refund for the parent order' do + order.cancel! + + expect(parent_order.reload.refunds.count).to eq(1) + expect(parent_order.refunds.last.amount).to eq(order.total + order.platform_fee_total.abs) + end + end + + context 'multiple sub-orders' do + let!(:order_2) { create(:vendor_order_ready_to_ship, store: store, parent: parent_order) } + + context 'only 1 sub-order is canceled' do + it 'marks the parent order as partially canceled' do + order.cancel + parent_order.reload + expect(parent_order.reload.state).to eq('partially_canceled') + end + end + + context 'all sub-orders are canceled' do + before do + parent_order.line_items.destroy_all + + parent_order.payments.update_all(state: 'completed', amount: 200) + parent_order.update_columns(shipment_total: 20) + end + + it 'marks the parent order as canceled' do + order.cancel + expect(parent_order.reload.state).to eq('partially_canceled') + order_2.cancel + parent_order.reload + expect(parent_order.reload).to be_canceled + expect(parent_order.payment_state).to eq 'void' + expect(parent_order.shipment_state).to eq 'canceled' + end + + it 'refunds shipment cost' do + order.cancel + order_2.cancel + + expect(parent_order.refunds.count).to eq(2) + expect(parent_order.refunds.map(&:amount).uniq).to contain_exactly(order.total + order.platform_fee_total.abs) + expect(parent_order.refunds.map(&:amount).uniq).to contain_exactly(order_2.total + order_2.platform_fee_total.abs) + end + end + end end end - context 'vendor methods' do - let!(:shipment) { create(:shipment, order: order, cost: 20, pre_tax_amount: 20, stock_location: vendor.stock_locations.first) } - let!(:promotion) { create(:promotion_with_item_adjustment, adjustment_rate: 10, code: '10off') } + describe '#persist_totals' do + let(:order) { create(:order_with_line_items) } - before do - order.coupon_code = '10off' - Spree::PromotionHandler::Coupon.new(order).apply - order.reload + it 'updates the platform fee total' do + expect(order).to receive(:update_column).with(:platform_fee_total, order.platform_fee_total) + order.persist_totals end + end - describe '#vendor_line_items' do - it { expect(order.vendor_line_items(vendor)).to eq(order.line_items.for_vendor(vendor)) } - it { expect(order.vendor_line_items(vendor).count).to eq(3) } + describe '#can_vendor_cancel?' do + context 'when order is not completed' do + let(:order) { create(:order, state: 'splitted') } + + it 'returns false' do + expect(order.can_vendor_cancel?).to be false + end end - describe '#vendor_shipments' do - it { expect(order.vendor_shipments(vendor).to_a).to eq([shipment]) } - it { expect(order.vendor_shipments(vendor).count).to eq(1) } + context 'when order is canceled' do + let(:order) { create(:order, state: 'canceled') } + + it 'returns false' do + expect(order.can_vendor_cancel?).to be false + end end - describe '#vendor_subtotal' do - it { expect(order.vendor_subtotal(vendor)).to eq(320) } + context 'when vendor is not present' do + let!(:order) { create(:vendor_order, vendor: nil) } + + it 'returns false' do + expect(order.can_vendor_cancel?).to be false + end end - describe '#display_subtotal' do - it { expect(order.display_vendor_subtotal(vendor).to_s).to eq('$320.00') } + context 'when order wasn\'t created in external store' do + let(:order) { create(:vendor_completed_order_with_totals, vendor: create(:approved_vendor)) } + + it 'returns true' do + expect(order.can_cancel_in_admin?).to be true + end end + end - describe '#vendor_pre_tax_item_amount' do - it { expect(order.vendor_pre_tax_item_amount(vendor)).to eq(300) } + describe '#can_cancel_in_admin?' do + context 'when order is not completed' do + let(:order) { create(:order, state: 'splitted') } + + it 'returns false' do + expect(order.can_cancel_in_admin?).to be false + end end - describe '#displayvendor_pre_tax_item_amount' do - it { expect(order.display_vendor_pre_tax_item_amount(vendor).to_s).to eq('$300.00') } + context 'when order is canceled' do + let(:order) { create(:order, state: 'canceled') } + + it 'returns false' do + expect(order.can_cancel_in_admin?).to be false + end end - describe '#vendor_pre_tax_total' do - it { expect(order.vendor_pre_tax_total(vendor)).to eq(320) } + context 'when order is parent order' do + let!(:order) { create(:vendor_completed_order_with_totals, vendor: create(:approved_vendor)) } + + it 'returns false' do + expect(order.parent.can_cancel_in_admin?).to be false + end end - describe '#displayvendor_pre_tax_total' do - it { expect(order.display_vendor_pre_tax_total(vendor).to_s).to eq('$320.00') } + context 'when order wasn\'t created in external store' do + let(:order) { create(:vendor_completed_order_with_totals, vendor: create(:approved_vendor)) } + + it 'returns true' do + expect(order.can_cancel_in_admin?).to be true + end end - context 'ship total' do + context 'when order is already shipped' do + let(:order) do + create(:vendor_order_ready_to_ship, vendor: create(:approved_vendor)) + end + before do - order.vendor_shipments(vendor).update_all(adjustment_total: 5) + order.shipments.find_each(&:ship!) end - describe '#vendor_ship_total' do - it { expect(order.vendor_ship_total(vendor)).to eq(25) } + it 'returns false' do + expect(order.can_cancel_in_admin?).to be false end + end + end - describe '#display_vendor_ship_total' do - it { expect(order.display_vendor_ship_total(vendor).to_s).to eq('$25.00') } + context 'multi vendor order' do + let(:parent_order) { create(:completed_order_with_totals) } + + let(:vendor) { create(:approved_vendor) } + let(:vendor_2) { create(:approved_vendor) } + + let(:product) { create(:product_in_stock, vendor: vendor, price: 90.0) } + let(:product_2) { create(:product_in_stock, vendor: vendor, price: 49.0) } + let(:product_3) { create(:product_in_stock, vendor: vendor_2, price: 100.0) } + + let!(:line_item) { create(:line_item, order: parent_order, product: product, price: product.price) } + let!(:line_item_2) { create(:line_item, order: parent_order, product: product_2, price: product_2.price) } + let!(:line_item_3) { create(:line_item, order: parent_order, product: product_3, price: product_3.price) } + + let!(:payment) { create(:payment, order: parent_order) } + + describe '#split!' do + it { expect { parent_order.split! }.to change { parent_order.state }.from('complete').to('splitted') } + end + + it 'does not send regular confirmation email' do + expect(Spree::OrderMailer).not_to receive(:confirm_email) + parent_order.send(:deliver_order_confirmation_email) + end + end + + describe '#deliver_splitted_order_confirmation_email' do + let(:parent_order) { create(:completed_order_with_totals) } + + context 'when send_consumer_transactional_emails store setting is set to true' do + it 'sends splitted order confirmation email after reaching splitted state' do + expect(Spree::OrderMailer).to receive_message_chain(:splitted_order_confirm_email, :deliver_later).and_return true + parent_order.deliver_splitted_order_confirmation_email + end + + it 'updates confirmation_delivered to true' do + parent_order.deliver_splitted_order_confirmation_email + expect(parent_order.reload.confirmation_delivered).to be true + end + end + + context 'when send_consumer_transactional_emails store setting is set to false' do + before do + parent_order.store.preferred_send_consumer_transactional_emails = false + parent_order.store.save! end - describe '#vendor_pre_tax_ship_amount' do - it { expect(order.vendor_pre_tax_ship_amount(vendor)).to eq(20) } + it 'does not send splitted order confirmation email after reaching splitted state' do + expect(Spree::OrderMailer).not_to receive(:splitted_order_confirm_email) + parent_order.deliver_splitted_order_confirmation_email end - describe '#display_vendor_pre_tax_ship_amount' do - it { expect(order.display_vendor_pre_tax_ship_amount(vendor).to_s).to eq('$20.00') } + it 'does not update confirmation_delivered to true' do + parent_order.deliver_splitted_order_confirmation_email + expect(parent_order.reload.confirmation_delivered).to be false end end + end + + describe '#deliver_vendor_order_notification_email' do + let(:vendor) { create(:vendor) } + let!(:vendor_user) { create(:vendor_user, vendor: vendor) } + let(:vendor_order) { create(:vendor_completed_order_with_totals, vendor: vendor, store: store) } - describe '#vendor_promo_total' do - it { expect(order.vendor_promo_total(vendor)).to eq(-30) } + context 'when deliver_vendor_order_notification_email? returns true' do + before do + allow(vendor_order).to receive(:deliver_vendor_order_notification_email?).and_return(true) + clear_enqueued_jobs + end + + it 'sends splitted order confirmation email after reaching splitted state' do + expect do + vendor_order.deliver_vendor_order_notification_email && perform_enqueued_jobs(except: Spree::Addresses::GeocodeAddressJob) + end.to change { Spree::OrderMailer.deliveries.count }.by 1 + end + + it 'updates confirmation_delivered to true' do + vendor_order.deliver_vendor_order_notification_email + expect(vendor_order.reload.confirmation_delivered).to be true + end end - describe '#display_vendor_promo_total' do - it { expect(order.display_vendor_promo_total(vendor).to_s).to eq('-$30.00') } + context 'when deliver_vendor_order_notification_email? returns false' do + before { allow(vendor_order).to receive(:deliver_vendor_order_notification_email?).and_return(false) } + + it 'does not send splitted order confirmation email after reaching splitted state' do + perform_enqueued_jobs(except: Spree::Addresses::GeocodeAddressJob) + expect(Spree::OrderMailer).not_to receive(:vendor_order_confirm_email) + vendor_order.deliver_vendor_order_notification_email + end + + it 'does not update confirmation_delivered to true' do + vendor_order.deliver_vendor_order_notification_email + expect(vendor_order.reload.confirmation_delivered).to be false + end end - context 'additional tax' do + context 'when in test mode' do before do - order.vendor_line_items(vendor).update_all(additional_tax_total: 10) - order.vendor_shipments(vendor).update_all(additional_tax_total: 1) + allow(store).to receive(:preferred_test_mode).and_return(true) end - describe '#vendor_additional_tax_total' do - it { expect(order.vendor_additional_tax_total(vendor)).to eq(31) } + it 'does not send vendor order notification email' do + expect(Spree::OrderMailer).not_to receive(:vendor_order_confirm_email) + vendor_order.deliver_vendor_order_notification_email end - describe '#display_vendor_additional_tax_total' do - it { expect(order.display_vendor_additional_tax_total(vendor).to_s).to eq('$31.00') } + it 'does not update confirmation_delivered to true' do + perform_enqueued_jobs + vendor_order.deliver_vendor_order_notification_email + expect(vendor_order.reload.confirmation_delivered).to be false end end + end - context 'included tax' do - before do - order.vendor_line_items(vendor).update_all(included_tax_total: 5) - order.vendor_shipments(vendor).update_all(included_tax_total: 1) + describe '#deliver_vendor_order_notification_email?' do + context 'when order is not splitted' do + let(:order) { create(:order, parent: nil) } + + it 'returns false' do + expect(order.deliver_vendor_order_notification_email?).to be_falsey end + end - describe '#vendor_included_tax_total' do - it { expect(order.vendor_included_tax_total(vendor)).to eq(16) } + context 'when confirmation was delivered' do + let(:order) { create(:order, parent: create(:order), vendor: create(:vendor), confirmation_delivered: true) } + + it 'returns false' do + expect(order.deliver_vendor_order_notification_email?).to be_falsey end + end - describe '#display_vendor_included_tax_total' do - it { expect(order.display_vendor_included_tax_total(vendor).to_s).to eq('$16.00') } + context 'when order is splitted and confirmation has not been delivered' do + let(:order) { create(:order, parent: create(:order), vendor: create(:vendor)) } + + it 'returns true' do + expect(order.deliver_vendor_order_notification_email?).to be_truthy end end + end + + describe '#after_cancel' do + context 'with the gift card covering the whole payment' do + let(:order) { create(:vendor_completed_order_with_totals, parent: parent_order) } + let(:parent_order) { create(:splitted_order) } + + let(:gift_card) { create(:gift_card, amount: 50) } + + let!(:store_credit_payment_1) { create(:store_credit_payment, order: order, state: 'completed', amount: 40) } + let!(:store_credit_payment_2) { create(:store_credit_payment, order: order, state: 'invalid', amount: 40) } - describe '#vendor_total' do - it { expect(order.vendor_total(vendor)).to eq(290) } + before do + parent_order.update_column(:total, 50) + parent_order.apply_gift_card(gift_card) + + order.update_column(:total, 40) + end + + it 'voids the completed store credit payments' do + order.after_cancel + + expect(store_credit_payment_1.reload.state).to eq('void') + expect(store_credit_payment_2.reload.state).to eq('invalid') + end end + end - describe '#display_vendor_total' do - it { expect(order.display_vendor_total(vendor).to_s).to eq('$290.00') } + describe 'shipment state' do + let(:order) { create(:order_ready_to_ship) } - context 'respects currency' do - before { order.currency = 'GBP' } + context 'when there are multiple shipment states' do + before do + create_list(:shipment, 2, order: order, state: 'ready') + end - it { expect(order.display_vendor_total(vendor).to_s).to eq('£290.00') } + context 'when one of the shipments is shipped' do + before do + order.shipments.reload.second.update(state: 'shipped') + end + + it do + order.update_with_updater! + expect(order.reload.shipment_state).to eq('partial') + end + end + + context 'when of the shipments is pending' do + before do + order.shipments.reload.second.update(state: 'pending') + end + + it do + order.update_with_updater! + expect(order.reload.shipment_state).to eq('pending') + end end end - describe '#vendor_item_count' do - before { order.vendor_line_items(vendor).last.update(quantity: 2) } + context 'when all shipments have the same state' do + before do + create_list(:shipment, 2, order: order, state: 'shipped') + order.shipments.update_all(state: 'shipped') + end - it { expect(order.vendor_item_count(vendor)).to eq(4) } + it do + order.update_with_updater! + expect(order.reload.shipment_state).to eq('shipped') + end end + end - describe '#vendor_ids' do - it { expect(order.vendor_ids).to match_array([vendor.id, vendor_2.id]) } + describe '#total_after_fees' do + subject(:total_after_fees) { order.total_after_fees } - context 'when a line item does not belong to any vendor' do - before { order.vendor_line_items(vendor_2).each { |li| li.product.update_columns(vendor_id: nil) } } - it { expect(order.vendor_ids).to match_array([vendor.id]) } + context 'for a parent order' do + let(:order) { create(:order, total: 100, platform_fee_total: 0) } + + it 'returns the order total' do + expect(total_after_fees).to eq(100) end end - describe '#vendor_totals' do - it { expect(order.vendor_totals).to be_kind_of(Array) } - it { expect(order.vendor_totals).not_to be_empty } - it { expect(order.vendor_totals.first.order).to eq(order) } - it { expect(order.vendor_totals.map(&:vendor)).to match_array([vendor, vendor_2]) } + context 'for a suborder' do + let(:order) { create(:vendor_order, total: 100, platform_fee_total: -10) } + + it 'returns the order total excluding platform fee total' do + expect(total_after_fees).to eq(90) + end end end end diff --git a/spec/models/spree/payment_spec.rb b/spec/models/spree/payment_spec.rb new file mode 100644 index 00000000..fa2c2e44 --- /dev/null +++ b/spec/models/spree/payment_spec.rb @@ -0,0 +1,75 @@ +describe Spree::Payment, type: :model do + context 'vendorized payment' do + it 'assigns vendor from order' do + order = create(:vendor_order_with_totals, total: 100) + payment = create(:payment, order: order) + expect(payment.vendor).to eq(order.vendor) + end + end + + describe 'can_credit?' do + subject { payment.can_credit? } + + let(:payment) { create(:payment, order: order, amount: 10) } + + before do + allow(payment).to receive(:credit_allowed).and_return(credit_allowed) + end + + context 'when the payment is not on a vendor order' do + let(:order) { create(:order, total: 100) } + + context 'when credit allowed is greater than 0' do + let(:credit_allowed) { 10 } + it { is_expected.to be(true) } + end + + context 'when credit allowed is 0' do + let(:credit_allowed) { 0 } + it { is_expected.to be(false) } + end + + context 'when the payment is a store credit' do + let(:payment) { create(:store_credit_payment, order: order, amount: 10) } + + context 'when the credit allowed is greater than 0' do + let(:credit_allowed) { 10 } + it { is_expected.to be(true) } + end + + context 'when the credit allowed is 0' do + let(:credit_allowed) { 0 } + it { is_expected.to be(false) } + end + end + end + + context 'when the payment is on a vendor order' do + let(:order) { create(:vendor_order, total: 100) } + + context 'when credit allowed is greater than 0' do + let(:credit_allowed) { 10 } + it { is_expected.to be(true) } + end + + context 'when credit allowed is 0' do + let(:credit_allowed) { 0 } + it { is_expected.to be(false) } + end + + context 'when the payment is a store credit' do + let(:payment) { create(:store_credit_payment, order: order, amount: 10) } + + context 'when the credit allowed is greater than 0' do + let(:credit_allowed) { 10 } + it { is_expected.to be(false) } + end + + context 'when the credit allowed is 0' do + let(:credit_allowed) { 0 } + it { is_expected.to be(false) } + end + end + end + end +end diff --git a/spec/models/spree/product_spec.rb b/spec/models/spree/product_spec.rb index ac5eab79..85d9ab88 100644 --- a/spec/models/spree/product_spec.rb +++ b/spec/models/spree/product_spec.rb @@ -1,19 +1,104 @@ -require 'timecop' +require 'spec_helper' -describe Spree::Product do +RSpec.describe Spree::Product, type: :model do + let(:store) { Spree::Store.default } let(:vendor) { create(:vendor) } - let(:product) { create(:product, vendor: vendor) } - context '.whitelisted_ransackable_associations' do - it { expect(described_class.whitelisted_ransackable_associations).to include('vendor') } - it { expect(described_class.whitelisted_ransackable_associations.count).to be > 1 } + it 'has correct ransackable attributes' do + expect(described_class.whitelisted_ransackable_attributes).to include('vendor_id') end - it 'touches vendor after update' do - time = Time.current + 1.hour - product - Timecop.freeze(time) do - expect { product.touch }.to change { vendor.reload.updated_at } + context 'Callbacks' do + let(:product) { create(:product, stores: [store], vendor: vendor) } + + describe '#auto_match_taxons' do + let(:product) { create(:product) } + + context 'when product is created' do + let(:product) { build(:product) } + + it 'calls #auto_match_taxons' do + expect(product).to receive(:auto_match_taxons).at_least(:once) + product.save! + end + + context 'with automatic taxons' do + let!(:taxon) { create(:automatic_taxon, store: Spree::Store.default) } + + it 'enqueues Spree::Products::AutoMatchTaxonsJob' do + expect { product.save! }.to have_enqueued_job(Spree::Products::AutoMatchTaxonsJob).exactly(:once) + end + end + + context 'without automatic taxons' do + before do + product.stores.first.taxons.automatic.delete_all + end + + it 'does not enqueue Spree::Products::AutoMatchTaxonsWorker' do + expect { product.save! }.to_not have_enqueued_job(Spree::Products::AutoMatchTaxonsJob) + end + end + end + + context 'when product is updated' do + context 'when tags are updated' do + it 'calls #auto_match_taxons' do + expect(product.reload).to receive(:auto_match_taxons) + product.update!(tag_list: 'eco, vegan') + end + end + + context 'when available_on is updated' do + it 'calls #auto_match_taxons' do + expect(product.reload).to receive(:auto_match_taxons) + product.update!(available_on: 2.days.from_now) + end + end + + context 'when any other attribute is updated' do + it 'doesnt call #auto_match_taxons' do + expect(product.reload).not_to receive(:auto_match_taxons) + product.update!(name: 'new name') + end + end + end + + context 'when product is touched' do + it 'doesnt call #auto_match_taxons' do + expect(product.reload).not_to receive(:auto_match_taxons) + product.touch + end + end + end + + describe '#update_vendor_products_count' do + it 'updates vendor products count' do + product + expect { create(:product, stores: [store], vendor: vendor) }.to change { vendor.reload.products_count }.by(1) + expect { product.destroy }.to change { vendor.reload.products_count }.by(-1) + end + + it 'updates vendor updated_at' do + product = build(:product, stores: [store], vendor: vendor) + + expect { product.save! }.to change(vendor, :updated_at) + expect { product.destroy }.to change { vendor.reload.updated_at } + end + end + end + + describe '#restore' do + let!(:product) { create :product_in_stock } + + before do + product.destroy! + create(:product_in_stock, name: product.name, stores: product.stores, vendor: product.vendor) + end + + it 'restores product' do + expect { product.restore }.to change { product.reload.deleted_at }.to(nil) + expect(product.slug).not_to include('deleted') end end end diff --git a/spec/models/spree/product_with_vendor_spec.rb b/spec/models/spree/product_with_vendor_spec.rb new file mode 100644 index 00000000..382a0e98 --- /dev/null +++ b/spec/models/spree/product_with_vendor_spec.rb @@ -0,0 +1,14 @@ +require 'timecop' + +describe Spree::Product do + let(:vendor) { create(:vendor) } + let(:product) { create(:product, vendor: vendor) } + + it 'does not touch vendor after update' do + time = Time.current + 1.hour + product + Timecop.freeze(time) do + expect { product.touch }.not_to change { vendor.reload.updated_at } + end + end +end diff --git a/spec/models/spree/shipment_spec.rb b/spec/models/spree/shipment_spec.rb new file mode 100644 index 00000000..f016193f --- /dev/null +++ b/spec/models/spree/shipment_spec.rb @@ -0,0 +1,72 @@ +require 'spec_helper' + +RSpec.describe Spree::Shipment do + let(:store) { Spree::Store.default } + let(:shipment) { create(:shipment, state: :ready, order: order, stock_location: stock_location) } + let(:stock_location) { create(:stock_location, vendor: vendor) } + let(:vendor) { create(:vendor) } + let(:order) do + create( + :vendor_order_ready_to_ship, + parent: parent_order, + store: store, + vendor: vendor, + total: payment_amount + ) + end + let(:payment) do + create( + :payment, + state: :completed, + amount: payment_amount, + order: order, + payment_method: gateway + ) + end + let!(:gateway) { create(:credit_card_payment_method, stores: [store]) } + let(:payment_amount) { 50.0 } + let(:parent_order) { create(:order, state: 'splitted', parent: nil, number: 'PARENT', completed_at: Time.current) } + + before do + order.update(payment_total: 0) + payment + end + + describe '#cancel' do + it 'allows shipped shipment to be canceled' do + shipment.ship! + expect(shipment.shipped?).to eq true + + shipment.cancel! + expect(shipment.canceled?).to eq true + end + end + + context 'vendor order shipment' do + let(:parent_order) { create(:order, shipment_state: 'ready', state: 'splitted', completed_at: Time.current) } + let!(:vendor_order) { create(:vendor_order, vendor: create(:vendor), parent: parent_order, shipment_state: 'ready') } + let!(:shipment) do + create(:shipment, state: 'ready', order: vendor_order, tracking: '123456789') + end + + before do + order.shipments.delete_all + allow(vendor_order).to receive(:can_ship?).and_return(true) + end + + it 'updates both vendor and parent order state' do + expect { shipment.ship! }.to change { vendor_order.reload.shipment_state }.from('ready').to('shipped').and change { + parent_order.reload.shipment_state + }.from('ready').to('shipped') + end + + context 'multiple shipments with diferent statuses' do + let!(:vendor_order_2) { create(:vendor_order, vendor: create(:vendor), parent: parent_order, shipment_state: 'ready') } + let!(:shipment2) { create(:shipment, state: 'ready', order: vendor_order) } + + it 'updates both vendor and parent order state' do + expect { shipment.ship! }.to change { parent_order.reload.shipment_state }.from('ready').to('partial') + end + end + end +end diff --git a/spec/models/spree/shipment_with_vendor_spec.rb b/spec/models/spree/shipment_with_vendor_spec.rb new file mode 100644 index 00000000..be4193f7 --- /dev/null +++ b/spec/models/spree/shipment_with_vendor_spec.rb @@ -0,0 +1,11 @@ +require 'spec_helper' + +describe Spree::Shipment, type: :model do + context 'vendorized shipment' do + it 'assigns vendor from stock location' do + stock_location = create(:stock_location, vendor: create(:vendor)) + shipment = create(:shipment, stock_location: stock_location) + expect(shipment.vendor).to eq(stock_location.vendor) + end + end +end diff --git a/spec/models/spree/stock/coordinator_spec.rb b/spec/models/spree/stock/coordinator_spec.rb new file mode 100644 index 00000000..57fc00c0 --- /dev/null +++ b/spec/models/spree/stock/coordinator_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper' + +RSpec.describe Spree::Stock::Coordinator, type: :model do + let(:coordinator) { described_class.new(order) } + + describe '#build_packages' do + subject(:packages) { coordinator.build_packages } + + shared_examples_for 'building one package' do + let(:stock_location) { Spree::StockLocation.last } + + let(:order_products) { order.products } + let(:order_stock_locations) { order_products.flat_map(&:stock_items).map(&:stock_location).uniq } + + let(:package_stock_location) { packages.first.stock_location } + let(:package_products) { packages.first.contents.map(&:variant).map(&:product) } + + it 'builds one package for the same stock location' do + expect(order_stock_locations).to contain_exactly(stock_location) + expect(packages.count).to eq(1) + expect(package_stock_location.vendor).to eq(vendor) + expect(package_products).to contain_exactly(*order_products) + end + end + + context 'for a single vendor' do + let(:order) { create(:vendor_order_with_line_items, line_items_count: 2, vendor: vendor) } + let(:vendor) { create(:vendor) } + + include_examples 'building one package' + end + + context 'for multiple vendors' do + let(:order) { create(:order, line_items: vendor_line_items) } + let(:vendor_line_items) { create_list(:vendor_line_item, 2) } + + let(:vendor_1_line_item) { vendor_line_items[0] } + let(:vendor_1) { vendor_line_items[0].vendor } + + let(:vendor_2_line_item) { vendor_line_items[1] } + let(:vendor_2) { vendor_line_items[1].vendor } + + let(:order_stock_locations) { order.products.flat_map(&:stock_items).map(&:stock_location).uniq } + let(:package_stock_locations) { packages.map(&:stock_location) } + + it 'builds one package for the same stock location per vendor' do + expect(package_stock_locations).to contain_exactly(*order_stock_locations) + + vendor_1_package = packages.find { |package| package.stock_location.vendor == vendor_1 } + vendor_1_package_products = vendor_1_package.contents.map(&:variant).map(&:product) + expect(vendor_1_package_products).to contain_exactly(vendor_1_line_item.product) + + vendor_2_package = packages.find { |package| package.stock_location.vendor == vendor_2 } + vendor_2_package_products = vendor_2_package.contents.map(&:variant).map(&:product) + expect(vendor_2_package_products).to contain_exactly(vendor_2_line_item.product) + end + end + + context 'without vendors' do + let!(:order) { create(:order_with_line_items, line_items_count: 2) } + let(:vendor) { nil } + + include_examples 'building one package' + end + end +end diff --git a/spec/models/spree/stock/package_with_vendor_spec.rb b/spec/models/spree/stock/package_with_vendor_spec.rb new file mode 100644 index 00000000..8ae31235 --- /dev/null +++ b/spec/models/spree/stock/package_with_vendor_spec.rb @@ -0,0 +1,63 @@ +require 'spec_helper' + +module Spree + module Stock + describe Package, type: :model do + subject { Package.new(stock_location, contents) } + + let(:vendor) { create(:approved_vendor) } + let(:product) { create(:product, vendor: vendor) } + let(:variant) { product.master } + let(:stock_location) { build(:stock_location, vendor: vendor) } + let(:order) { create(:order) } + let(:contents) { [] } + + def build_inventory_unit + build(:inventory_unit, variant: variant, order: order) + end + + # Contains regression test for #2804 + it 'builds a list of shipping methods common to all categories for stock location vendor' do + category1 = create(:shipping_category) + category2 = create(:shipping_category) + method1 = create(:shipping_method, vendor: vendor) + method2 = create(:shipping_method, vendor: vendor) + method3 = create(:shipping_method, vendor: create(:vendor)) + method4 = create(:shipping_method) + method5 = create(:shipping_method, vendor: vendor) + method1.shipping_categories = [category1, category2] + method2.shipping_categories = [category1] + method3.shipping_categories = [category1, category2] + method4.shipping_categories = [category1, category2] + method5.shipping_categories = [category1, category2] + variant1 = create(:product, shipping_category: category1, vendor: vendor).master + variant2 = create(:product, shipping_category: category2, vendor: vendor).master + contents = [ContentItem.new(build(:inventory_unit, variant_id: variant1.id)), + ContentItem.new(build(:inventory_unit, variant_id: variant1.id)), + ContentItem.new(build(:inventory_unit, variant_id: variant2.id))] + + package = Package.new(stock_location, contents) + expect(package.shipping_methods.pluck(:id)).to contain_exactly(method1.id, method5.id) + end + + context 'shipping vendor product from non-vendor stock location' do + let(:central_stock_location) { build(:stock_location, vendor: nil) } + + it 'builds a list of shipping methods common to all categories' do + category1 = create(:shipping_category) + category2 = create(:shipping_category) + method1 = create(:shipping_method) + method2 = create(:shipping_method, vendor: vendor) + method1.shipping_categories = [category1, category2] + method2.shipping_categories = [category1, category2] + variant1 = create(:product, shipping_category: category1).master + contents = [ContentItem.new(build(:inventory_unit, variant_id: variant1.id)), + ContentItem.new(build(:inventory_unit, variant_id: variant1.id))] + + package = Package.new(central_stock_location, contents) + expect(package.shipping_methods).to eq([method1]) + end + end + end + end +end diff --git a/spec/models/spree/variant_spec.rb b/spec/models/spree/variant_spec.rb index ee4205dc..48e0dbb6 100644 --- a/spec/models/spree/variant_spec.rb +++ b/spec/models/spree/variant_spec.rb @@ -1,104 +1,9 @@ require 'spec_helper' describe Spree::Variant do - let(:vendor) { create :vendor } - let(:other_vendor) { create :vendor, name: 'Other vendor' } - let(:product) { create(:product, vendor: vendor) } - subject { build(:variant, product: product) } - - context 'after create' do - it "propagate to stock items only for Vendor's Stock Locations" do - expect { create(:variant, product: product) }.to change { - Spree::StockItem.where(stock_location: vendor.stock_locations.first).count - } - end - - it "doesn't propagate to stock items for other vendors Stock Locations" do - expect { create(:variant, product: product) }.to_not change { - Spree::StockItem.where(stock_location: other_vendor.stock_locations.first).count - } + describe 'disable_sku_validation?' do + it 'returns true' do + expect(Spree::Variant.new.disable_sku_validation?).to be_truthy end end - - describe '#assign_vendor' do - context 'product vendorized, variant vendorized' do - it 'copies from product' do - expect(subject.attributes['vendor_id']).to be_nil - subject.save! - expect(subject.reload.attributes['vendor_id']).to eq(product.vendor_id) - end - end - - context 'product not vendorized, variant vendorized' do - before do - SpreeMultiVendor::Config[:vendorized_models] = ['variant'] - end - - after do - SpreeMultiVendor::Config[:vendorized_models] = SpreeMultiVendor::Configuration::DEFAULT_VENDORIZED_MODELS - end - - context 'no vendor' do - let(:product) { create(:product) } - - it { expect { subject.save! }.not_to change { subject.vendor }.from(nil) } - end - - context 'vendor assigned' do - subject { build(:variant, product: product, vendor: vendor) } - - it { expect { subject.save! }.not_to change { subject.vendor }.from(vendor) } - end - end - - context 'product vendorized, variant not vendorized' do - before do - SpreeMultiVendor::Config[:vendorized_models] = ['product'] - end - - after do - SpreeMultiVendor::Config[:vendorized_models] = SpreeMultiVendor::Configuration::DEFAULT_VENDORIZED_MODELS - end - - it { expect { subject.save! }.not_to raise_error } - end - end - - describe '#vendor' do - context 'product vendorized, variant vendorized' do - before { subject.save! } - - it { expect(subject.vendor).to eq(product.vendor) } - end - - context 'product not vendorized, variant vendorized' do - before do - SpreeMultiVendor::Config[:vendorized_models] = ['variant'] - end - - after do - SpreeMultiVendor::Config[:vendorized_models] = SpreeMultiVendor::Configuration::DEFAULT_VENDORIZED_MODELS - end - - let(:product) { create(:product) } - subject { build(:variant, product: product, vendor: vendor) } - - it { expect(subject.vendor).to eq(vendor) } - end - - context 'product vendorized, variant not vendorized' do - before do - SpreeMultiVendor::Config[:vendorized_models] = ['product'] - subject.save! - end - - after do - SpreeMultiVendor::Config[:vendorized_models] = SpreeMultiVendor::Configuration::DEFAULT_VENDORIZED_MODELS - end - - it { expect(subject.vendor).to eq(product.vendor) } - end - end - - it { expect(described_class.whitelisted_ransackable_associations).to include('vendor') } end diff --git a/spec/models/spree/vendor_order_totals_spec.rb b/spec/models/spree/vendor_order_totals_spec.rb deleted file mode 100644 index f47d5d7c..00000000 --- a/spec/models/spree/vendor_order_totals_spec.rb +++ /dev/null @@ -1,33 +0,0 @@ -require 'spec_helper' - -describe Spree::VendorOrderTotals do - let(:vendor) { create(:vendor) } - let(:order) { create(:completed_order_with_totals, line_items_count: 6, line_items_price: 100, shipment_cost: 50) } - let!(:shipment) { create(:shipment, order: order, cost: 20, stock_location: vendor.stock_locations.first) } - let!(:promotion) { create(:promotion_with_item_adjustment, adjustment_rate: 10, code: '10off') } - - subject { described_class.new(order: order, vendor: vendor) } - - before do - order.products.update_all(vendor_id: vendor.id) - order.vendor_line_items(vendor).update_all(additional_tax_total: 10) - order.vendor_line_items(vendor).update_all(included_tax_total: 5) - order.coupon_code = '10off' - Spree::PromotionHandler::Coupon.new(order).apply - order.reload - end - - Spree::VendorOrderTotals::METHOD_NAMES.each do |method_name| - describe "##{method_name}" do - it { expect(subject.send(method_name)).to eq(order.send("vendor_#{method_name}", vendor)) } - end - - describe "#display_#{method_name}" do - it { expect(subject.send("display_#{method_name}")).to eq(order.send("display_vendor_#{method_name}", vendor)) } - end - end - - describe '#item_count' do - it { expect(subject.item_count).to eq(order.vendor_item_count(vendor)) } - end -end diff --git a/spec/models/spree/vendor_spec.rb b/spec/models/spree/vendor_spec.rb index 019b92a5..0caf1175 100644 --- a/spec/models/spree/vendor_spec.rb +++ b/spec/models/spree/vendor_spec.rb @@ -1,26 +1,110 @@ require 'spec_helper' describe Spree::Vendor do - describe 'associations' do - it { is_expected.to have_many(:commissions) } - it { is_expected.to have_many(:products) } - it { is_expected.to have_many(:shipping_methods) } - it { is_expected.to have_many(:stock_locations) } - it { is_expected.to have_many(:variants) } - it { is_expected.to have_many(:vendor_users) } - it { is_expected.to have_many(:users).through(:vendor_users) } - it { is_expected.to have_one(:image) } - end - - describe 'validations' do - it { is_expected.to validate_presence_of(:name) } - it { is_expected.to allow_value('flower').for(:name) } - it { is_expected.to allow_value('flower455').for(:name) } - it { is_expected.to allow_value('flower 455').for(:name) } - it { is_expected.to allow_value('flower abc').for(:name) } - it { is_expected.to allow_value('flower455 abc').for(:name) } - it { is_expected.to allow_value('flower 455 abc').for(:name) } - it { is_expected.to allow_value('flower & bucket').for(:name) } + let(:store) { Spree::Store.default } + let!(:store_admin) { create(:admin_user) } + + describe 'Validations' do + let(:vendor) { build(:vendor) } + + describe '#contact_person_cannot_be_store_admin' do + it 'is invalid' do + vendor.contact_person_email = store.users.first.email + + expect(vendor).to_not be_valid + expect(vendor.errors.messages).to include(:contact_person_email) + expect(vendor.errors.full_messages).to include('Contact person email cannot belong to a store admin') + end + + it 'is valid with a different email' do + expect(vendor).to be_valid + end + end + end + + describe 'Callbacks' do + describe '#create_default_policies' do + let!(:vendor) { build(:vendor) } + + it 'creates default policies' do + expect { vendor.save! }.to change(Spree::Policy, :count).by(SpreeMultiVendor::Config[:default_policies].count) + expect(vendor.policies.pluck(:name)).to contain_exactly(*SpreeMultiVendor::Config[:default_policies].map { |policy| Spree.t(policy) }) + end + end + + describe 'before_destroy :ensure_can_be_deleted' do + subject { vendor.destroy } + + let!(:vendor) { create(:approved_vendor) } + + context 'for a vendor with unfulfilled orders' do + let!(:vendor_order) { create(:vendor_order_ready_to_ship, vendor: vendor) } + + it 'responds with an error' do + subject + + expect(vendor.reload).to_not be_deleted + expect(vendor.errors.messages).to eq(base: ["Can't delete a partner with unfulfilled orders"]) + end + end + + context 'for a vendor with fulfilled orders' do + let!(:vendor_order) { create(:vendor_shipped_order, vendor: vendor) } + + it 'soft-deletes the vendor' do + subject + expect(vendor.reload).to be_deleted + end + end + end + + describe 'after_create' do + let!(:vendor) { build(:vendor) } + + it 'creates a stock location with default country if no integration is providen' do + expect { vendor.save! }.to change(Spree::StockLocation, :count).by(1) + stock_location = Spree::StockLocation.last + expect(vendor.stock_locations.first).to eq stock_location + expect(stock_location.country).to eq Spree::Store.default.default_country + end + end + + describe 'after_update' do + let!(:vendor) { create(:vendor) } + let!(:product_1) { create(:product, vendor: vendor) } + let!(:product_2) { create(:product, vendor: vendor) } + let!(:inactive_product) { create(:product, vendor: vendor, status: :draft) } + + it 'updates stock_location names when vendor name changed' do + old_name = vendor.name + new_name = 'new vendor name' + vendor.name = new_name + expect(vendor.stock_locations.map(&:name).uniq).to eq [old_name] + vendor.save! + expect(vendor.stock_locations.map(&:name).uniq).to eq [new_name] + end + end + end + + describe 'Translations' do + it 'has translatable about field' do + expect(described_class::TRANSLATABLE_FIELDS).to include(:about) + end + + it 'supports translations for about' do + vendor = create(:vendor, about: 'About Vendor') + + I18n.with_locale(:es) do + vendor.about = 'About Vendor in Spanish' + vendor.save! + end + + expect(vendor.about.to_plain_text).to eq('About Vendor') + + I18n.with_locale(:es) do + expect(vendor.about.to_plain_text).to eq('About Vendor in Spanish') + end + end end describe 'initial state' do @@ -29,29 +113,191 @@ end end - describe 'after_create' do - let!(:vendor) { build(:vendor) } + describe '#start_onboarding', job: true do + it 'should change state to onboarding' do + vendor = create(:vendor, state: 'pending') + vendor.start_onboarding + expect(vendor).to be_onboarding + vendor = create(:vendor, state: 'invited') + vendor.start_onboarding + expect(vendor).to be_onboarding + end + end + + describe '#complete_onboarding', job: true do + context 'happy path' do + before do + allow_any_instance_of(Spree::Vendor).to receive(:accepted?).and_return(true) + allow_any_instance_of(Spree::Vendor).to receive(:onboarding_completed?).and_return(true) + end + + it 'should change state to onboarding' do + vendor = create(:vendor, state: 'pending') + vendor.complete_onboarding + expect(vendor.state).to eq('ready_for_review') + vendor = create(:vendor, state: 'invited') + vendor.complete_onboarding + expect(vendor.state).to eq('ready_for_review') + vendor = create(:vendor, state: 'onboarding') + vendor.complete_onboarding + expect(vendor.state).to eq('ready_for_review') + end + end + + context 'cannot complete onboarding' do + before do + allow_any_instance_of(Spree::Vendor).to receive(:onboarding_completed?).and_return(false) + end - it 'creates a stock location with default country' do - expect { vendor.save! }.to change(Spree::StockLocation, :count).by(1) - stock_location = Spree::StockLocation.last - expect(vendor.stock_locations.first).to eq stock_location - expect(stock_location.country).to eq Spree::Store.default.default_country + it 'should not change state' do + vendor = create(:vendor, state: 'pending') + vendor.complete_onboarding + expect(vendor.state).to eq('pending') + vendor = create(:vendor, state: 'invited') + vendor.complete_onboarding + expect(vendor.state).to eq('invited') + vendor = create(:vendor, state: 'onboarding') + vendor.complete_onboarding + expect(vendor.state).to eq('onboarding') + end end + end + + context 'mails after state transitions' do + let!(:vendor) { create(:vendor, state: :pending) } + let(:mail_double) { double('Mail', deliver_later: true) } + let!(:marketplace_owner) { create(:admin_user) } + + context 'after Vendor is suspended' do + before { vendor.approve } + + it 'sends vendor_suspended emails to the vendor owners' do + expect(Spree::VendorMailer).to receive(:vendor_suspended).with(vendor).and_return(mail_double) + vendor.suspend + end + end + + context 'after Vendor is approved' do + let!(:vendor_owner) { create(:vendor_user, vendor: vendor) } + + before do + clear_enqueued_jobs + end + + it 'sends vendor_approved emails to vendor owners and marketplace owners' do + expect { vendor.approve && perform_enqueued_jobs }.to change { Spree::VendorMailer.deliveries.count }.by (vendor.users.count + vendor.store.users.count) + expect(Spree::VendorMailer.deliveries.map(&:to).flatten).to match_array [vendor.users.map(&:email) + vendor.store.users.map(&:email)].flatten + end + + context 'when there are many marketplace owners' do + let!(:additional_marketplace_owners) { create_list(:admin_user, 10) } + + it 'sends one vendor_approved email per marketplace owner' do + expect { vendor.approve && perform_enqueued_jobs }.to change { Spree::VendorMailer.deliveries.count }.by (vendor.users.count + vendor.store.users.count) + end + + it 'each email has only one recipient' do + vendor.approve && perform_enqueued_jobs + expect(Spree::VendorMailer.deliveries.map(&:to).map(&:length).uniq).to eq [1] + end + end + end + + context 'after Vendor is rejected' do + it 'sends vendor_rejected emails to the vendor owners' do + expect(Spree::VendorMailer).to receive(:vendor_rejected).with(vendor).and_return(mail_double) + vendor.reject + end + end + + context 'after Vendor starts onboarding' do + let!(:vendor) { create(:invited_vendor) } + + it 'sends vendor_onboarding_started to marketplace owners' do + expect { vendor.start_onboarding && perform_enqueued_jobs }.to change(Spree::VendorMailer.deliveries, :count).by(store.users.count + 1) + expect(Spree::VendorMailer.deliveries.map(&:to).flatten).to contain_exactly(*store.users.pluck(:email), *vendor.contact_person_email) + end + end + + context 'after vendor completes onboarding', job: true do + let!(:vendor) { create(:invited_vendor) } + + before do + allow(vendor).to receive(:onboarding_completed?).and_return(true) + clear_enqueued_jobs + end - it 'should act as list' do - expect(vendor).to respond_to(:set_list_position) + it 'updates vendors state onboarding -> ready_for_review' do + expect { vendor.save! }.to change(vendor, :state).to('ready_for_review') + end + + it 'sends vendor_onboarding_completed to store admins' do + expect { vendor.complete_onboarding! && perform_enqueued_jobs }.to change(Spree::VendorMailer.deliveries, :count).by(store.users.count) + expect(Spree::VendorMailer.deliveries.map(&:to).flatten).to contain_exactly(*store.users.pluck(:email)) + end + end + end + + context 'archiving products when suspended' do + let!(:vendor) { create(:approved_vendor) } + let!(:products) { create_list(:product, 5, vendor: vendor) } + let!(:other_vendor_product) { create(:product, vendor_id: 'other_vendor_id', status: 'active') } + + it 'archives all products of vendor in the background' do + expect { vendor.suspend }.to enqueue_job(SpreeMultiVendor::Vendors::ArchiveProductsJob).with(vendor.id) + end + + it 'touches Taxons and Taxonomies' do + product = products.first + taxon_ids = product.taxons.map(&:self_and_ancestors).flatten.uniq.map(&:id) + taxons_updated_at = Spree::Taxon.where(id: taxon_ids).order(:created_at).pluck(:updated_at) + vendor.suspend + + new_taxons_updated_at = Spree::Taxon.where(id: taxon_ids).order(:created_at).pluck(:updated_at) + taxons_updated_at.each_with_index do |old_updated_at, index| + expect(old_updated_at).to be_smaller_than(new_taxons_updated_at[index]) + end end end - describe 'after_update' do - let!(:vendor) { create(:vendor) } + context 'initial platform_fee' do + let!(:vendor) { build(:vendor, platform_fee: platform_fee) } + + context 'when platform fee is given' do + let(:platform_fee) { 17.00 } + + it 'sets it to given value' do + vendor.save + expect(vendor.platform_fee).to eq 17.00 + end + end + + context 'when platform fee is not given' do + let(:platform_fee) { nil } + + context 'when default store platform fee is set' do + before { vendor.store.update(preferred_platform_fee: 5.45) } + + it 'sets it to default Store platform_fee' do + vendor.save + expect(vendor.platform_fee).to eq 5.45 + end + end + end + end + + describe '#default_stock_location' do + let!(:vendor) { build(:vendor) } + let(:store) { Spree::Store.default } + + before { vendor.save! } - it 'updates stock_location names when vendor name changed' do - old_name = vendor.name - new_name = 'new vendor name' - vendor.name = new_name - expect { vendor.save! }.to change { vendor.stock_locations.pluck(:name).uniq }.from([old_name]).to([new_name]) + context 'default' do + it 'returns the vendor stock location' do + expect(vendor.default_stock_location).to eq(vendor.stock_locations.first) + expect(vendor.default_stock_location.name).to eq(vendor.name) + expect(vendor.default_stock_location.vendor).to eq(vendor) + end end end end diff --git a/spec/models/spree/vendor_user_spec.rb b/spec/models/spree/vendor_user_spec.rb deleted file mode 100644 index 55f5de86..00000000 --- a/spec/models/spree/vendor_user_spec.rb +++ /dev/null @@ -1,12 +0,0 @@ -require 'spec_helper' - -describe Spree::VendorUser do - describe 'associations' do - it { is_expected.to belong_to(:vendor) } - it { is_expected.to belong_to(:user) } - end - - describe 'validations' do - it { is_expected.to validate_uniqueness_of(:vendor_id).scoped_to(:user_id) } - end -end diff --git a/spec/models/spree_multi_vendor/permission_sets/vendor_user_spec.rb b/spec/models/spree_multi_vendor/permission_sets/vendor_user_spec.rb new file mode 100644 index 00000000..a69bcb11 --- /dev/null +++ b/spec/models/spree_multi_vendor/permission_sets/vendor_user_spec.rb @@ -0,0 +1,112 @@ +require 'spec_helper' + +RSpec.describe SpreeMultiVendor::PermissionSets::VendorUser, type: :model do + let(:vendor) { create(:approved_vendor) } + let(:vendor_user) { create(:vendor_user, vendor: vendor) } + + let(:ability) { Spree::Ability.new(vendor_user) } + + subject(:permission_set) { described_class.new(ability) } + + before { permission_set.activate! } + + describe '#activate!' do + context 'orders' do + let(:order) { create(:vendor_order, vendor: vendor) } + + it 'cannot create' do + expect(ability).not_to be_able_to :create, order + end + + it 'can admin' do + expect(ability).to be_able_to :admin, order + end + + it 'can show' do + expect(ability).to be_able_to :show, order + end + + it 'can index' do + expect(ability).to be_able_to :index, order + end + + it 'can edit' do + expect(ability).to be_able_to :edit, order + end + + it 'cannot update' do + expect(ability).to be_able_to :update, order + end + + it 'can cart' do + expect(ability).to be_able_to :cart, order + end + end + + context 'stock_locations' do + let(:other_vendor) { create(:approved_vendor) } + let(:stock_location) { create(:stock_location, vendor: vendor) } + let(:stock_location_other_vendor) { create(:stock_location, vendor: other_vendor) } + + it 'can manage stock locations that are related to the vendor' do + expect(ability).to be_able_to(:manage, stock_location) + expect(ability).to_not be_able_to(:manage, stock_location_other_vendor) + end + end + + context 'Spree::Taxon' do + let(:category) { create(:taxon) } + + it 'can admin' do + expect(ability).to be_able_to(:admin, category) + end + + it 'can select_options' do + expect(ability).to be_able_to(:select_options, category) + end + end + + context 'Spree::PaymentMethod' do + let!(:store) { Spree::Store.default } + let!(:payment_method) { create(:payment_method, stores: [store]) } + + it 'cannot create payment method' do + expect(ability).to_not be_able_to(:manage, payment_method) + end + end + + context 'Spree::ShippingMethod' do + let!(:country_zone) { create(:zone, name: 'CountryZone') } + let!(:shipping_method) { create(:shipping_method, zones: [country_zone]) } + + it 'can create shipping method' do + expect(ability).to be_able_to(:create, shipping_method) + end + end + + context 'Spree::Product' do + let!(:store) { Spree::Store.default } + let!(:product) { create(:product, vendor: vendor, stores: [store]) } + + it 'can create products' do + expect(ability).to be_able_to(:create, product) + end + + it 'cannot manage commision' do + expect(ability).to_not be_able_to(:manage_commission, product) + end + + it 'cannot activate product' do + expect(ability).to_not be_able_to(:activate, product) + end + end + + context 'Spree::Address' do + let!(:address) { create(:address, user: create(:user)) } + + it 'cannot create address' do + expect(ability).to_not be_able_to(:create, address) + end + end + end +end diff --git a/spec/requests/spree/api/v1/products_spec.rb b/spec/requests/spree/api/v1/products_spec.rb deleted file mode 100644 index 451ab9a6..00000000 --- a/spec/requests/spree/api/v1/products_spec.rb +++ /dev/null @@ -1,221 +0,0 @@ -require 'spec_helper' - -module Spree - describe 'API V1 Products Spec', type: :request do - let(:vendor) { create(:vendor, state: :active) } - let(:other_vendor) { create(:vendor) } - let(:shipping_category) { create(:shipping_category) } - let(:selected_product) { product_without_vendor } - let(:params) { {} } - - let!(:product_without_vendor) { create(:product) } - let!(:product_with_vendor_vendor) { create(:product, vendor: vendor) } - let!(:product_with_other_vendor) { create(:product, vendor: other_vendor) } - - before { stub_authentication! } - - describe 'products#index' do - before { get '/api/v1/products', params: params } - - context 'as a regular user' do - it 'gets a list of all products' do - expect(response.status).to eq(200) - expect(json_response[:products].size).to eq(3) - end - end - - context 'as an admin' do - sign_in_as_admin! - - it 'gets a list of all products' do - expect(response.status).to eq(200) - expect(json_response[:products].size).to eq(3) - end - end - - context 'as a vendor' do - let!(:current_api_user) do - user = create(:user, vendors: [vendor]) - user - end - - it 'gets a list of products assigned to that vendor' do - expect(response.status).to eq(200) - expect(json_response[:products].size).to eq(1) - expect(json_response[:products][0][:id]).to eq(vendor.products.first.id) - end - end - end - - describe 'products#show' do - before { get "/api/v1/products/#{selected_product.id}" } - - it 'gets a product' do - expect(response.status).to eq(200) - end - - context 'as a vendor' do - let!(:current_api_user) do - user = create(:user, vendors: [vendor]) - user - end - - describe 'cannot view product of another vendor' do - let(:selected_product) { product_with_other_vendor } - - it do - expect(response.status).to eq(404) - end - end - - describe 'can view product assigned to that vendor' do - let(:selected_product) { product_with_vendor_vendor } - - it do - expect(response.status).to eq(200) - end - end - end - end - - describe 'products#create' do - let(:params) do - { - product: { - name: 'Product', - shipping_category_id: shipping_category.id, - price: 12.34 - } - } - end - - before { post '/api/v1/products', params: params } - - context 'as regular user' do - it 'cannot create a new product' do - expect(response.status).to eq(401) - end - end - - context 'as an admin' do - sign_in_as_admin! - - it 'can create a new product' do - expect(response.status).to eq(201) - expect(json_response['name']).to eq(params[:product][:name]) - expect(json_response['price']).to eq(params[:product][:price].to_s) - expect(json_response['shipping_category_id']).to eq(params[:product][:shipping_category_id]) - end - end - - context 'as a vendor' do - let!(:current_api_user) do - user = create(:user, vendors: [vendor]) - user - end - - it 'can create a new product assigned to vendor' do - expect(response.status).to eq(201) - product = Spree::Product.find(json_response[:id]) - expect(product.vendor).to eq(vendor) - end - end - end - - describe 'products#update' do - let(:params) do - { - product: { - name: 'Updated Prodouct', - shipping_category_id: shipping_category.id, - price: 12.34 - } - } - end - - before { put "/api/v1/products/#{selected_product.id}", params: params } - - context 'as regular user' do - it 'cannot update product' do - expect(response.status).to eq(401) - end - end - - context 'as an admin' do - sign_in_as_admin! - - it 'can update product' do - expect(response.status).to eq(200) - expect(json_response['name']).to eq(params[:product][:name]) - expect(json_response['price']).to eq(params[:product][:price].to_s) - expect(json_response['shipping_category_id']).to eq(params[:product][:shipping_category_id]) - end - end - - context 'as a vendor' do - let!(:current_api_user) do - user = create(:user, vendors: [vendor]) - user - end - - describe 'cannot update product of another vendor' do - let(:selected_product) { product_with_other_vendor } - - it do - expect(response.status).to eq(404) - end - end - - describe 'can update product assigned to that vendor' do - let(:selected_product) { product_with_vendor_vendor } - - it do - expect(response.status).to eq(200) - end - end - end - end - - describe 'products#destroy' do - before { delete "/api/v1/products/#{selected_product.id}" } - - context 'as regular user' do - it 'cannot delete product' do - expect(response.status).to eq(401) - end - end - - context 'as an admin' do - sign_in_as_admin! - - it 'can delete product' do - expect(response.status).to eq(204) - expect(product_without_vendor.reload.deleted_at).to_not eq(nil) - end - end - - context 'as a vendor' do - let!(:current_api_user) do - user = create(:user, vendors: [vendor]) - user - end - - describe 'cannot delete product of another vendor' do - let(:selected_product) { product_with_other_vendor } - - it do - expect(response.status).to eq(404) - end - end - - describe 'can delete product assigned to that vendor' do - let(:selected_product) { product_with_vendor_vendor } - - it do - expect(response.status).to eq(204) - end - end - end - end - end -end diff --git a/spec/requests/spree/api/v1/users_spec.rb b/spec/requests/spree/api/v1/users_spec.rb deleted file mode 100644 index ce54bc79..00000000 --- a/spec/requests/spree/api/v1/users_spec.rb +++ /dev/null @@ -1,31 +0,0 @@ -require 'spec_helper' - -module Spree - describe 'API V1 Users Spec', type: :request do - let(:user) { create(:user) } - let(:vendor) { create(:vendor, state: :active) } - - before { stub_authentication! } - - describe 'users#update' do - let(:params) do - { - user: { - vendor_ids: [vendor.id] - } - } - end - - before { put "/api/v1/users/#{user.id}", params: params } - - context 'as an admin' do - sign_in_as_admin! - - it 'can update user' do - expect(response.status).to eq(200) - expect(json_response['vendor_ids']).to eq([vendor.id]) - end - end - end - end -end diff --git a/spec/requests/spree/api/v1/vendors_spec.rb b/spec/requests/spree/api/v1/vendors_spec.rb deleted file mode 100644 index 84bdcae4..00000000 --- a/spec/requests/spree/api/v1/vendors_spec.rb +++ /dev/null @@ -1,210 +0,0 @@ -require 'spec_helper' - -module Spree - describe 'API V1 Vendors Spec', type: :request do - let(:params) { {} } - let(:selected_vendor) { vendor } - - let!(:vendor) { create(:vendor, state: :active) } - let!(:another_vendor) { create(:vendor, state: :active) } - - before { stub_authentication! } - - describe 'vendors#index' do - before { get '/api/v1/vendors', params: params } - - context 'as a regular user' do - it 'gets an empty list' do - expect(response.status).to eq(200) - expect(json_response.size).to eq(0) - end - end - - context 'as an admin' do - sign_in_as_admin! - - it 'gets a list of all vendors' do - expect(response.status).to eq(200) - expect(json_response.size).to eq(2) - end - end - - context 'as a vendor' do - let!(:current_api_user) do - user = create(:user, vendors: [vendor]) - user - end - - it 'gets a list of assigned vendors' do - expect(response.status).to eq(200) - expect(json_response.size).to eq(1) - expect(json_response[0]['id']).to eq(vendor.id) - end - end - end - - describe 'vendors#show' do - before { get "/api/v1/vendors/#{selected_vendor.id}" } - - context 'as a regular user' do - it 'cannot view a vendor' do - expect(response.status).to eq(404) - end - end - - context 'as an admin' do - sign_in_as_admin! - it 'can view a vendor' do - expect(response.status).to eq(200) - end - end - - context 'as a vendor' do - let!(:current_api_user) do - user = create(:user, vendors: [vendor]) - user - end - - describe 'can view a assigned vendor' do - it do - expect(response.status).to eq(200) - end - end - - describe 'cannot view an another vendor' do - let(:selected_vendor) { another_vendor } - - it do - expect(response.status).to eq(404) - end - end - end - end - - describe 'vendors#create' do - let(:params) do - { - vendor: { - name: 'name' - } - } - end - - before { post '/api/v1/vendors', params: params } - - context 'as regular user' do - it 'cannot create a new vendor' do - expect(response.status).to eq(401) - end - end - - context 'as an admin' do - sign_in_as_admin! - - it 'can create a new vendor' do - expect(response.status).to eq(201) - expect(json_response['name']).to eq(params[:vendor][:name]) - end - end - - context 'as a vendor' do - let!(:current_api_user) do - user = create(:user, vendors: [vendor], email: FFaker::Internet.email) - user - end - - it 'cannot create a new vendor' do - expect(response.status).to eq(401) - end - end - end - - describe 'vendors#update' do - let(:params) do - { - vendor: { - name: 'updated name' - } - } - end - - before { put "/api/v1/vendors/#{selected_vendor.id}", params: params } - - context 'as regular user' do - it 'cannot update vendor' do - expect(response.status).to eq(404) - end - end - - context 'as an admin' do - sign_in_as_admin! - - it 'can update vendor' do - expect(response.status).to eq(200) - expect(json_response['name']).to eq(params[:vendor][:name]) - end - end - - context 'as a vendor' do - let!(:current_api_user) do - user = create(:user, vendors: [vendor]) - user - end - - describe 'can update as assigned vendor' do - it do - expect(response.status).to eq(200) - end - end - - describe 'cannot update an another vendor' do - let(:selected_vendor) { another_vendor } - - it do - expect(response.status).to eq(404) - end - end - end - end - - describe 'vendors#destroy' do - before { delete "/api/v1/vendors/#{selected_vendor.id}" } - - context 'as regular user' do - it 'cannot delete vendor' do - expect(response.status).to eq(404) - end - end - - context 'as an admin' do - sign_in_as_admin! - - it 'can delete vendor' do - expect(response.status).to eq(204) - expect(vendor.reload.deleted_at).to_not eq(nil) - end - end - - context 'as a vendor' do - let!(:current_api_user) do - user = create(:user, vendors: [vendor]) - user - end - - describe 'can delete an assigned vendor' do - it do - expect(response.status).to eq(204) - end - end - - describe 'cannot delete an another vendor' do - let(:selected_vendor) { another_vendor } - - it do - expect(response.status).to eq(404) - end - end - end - end - end -end diff --git a/spec/requests/spree/api/v2/platform/orders_spec.rb b/spec/requests/spree/api/v2/platform/orders_spec.rb new file mode 100644 index 00000000..c4b37659 --- /dev/null +++ b/spec/requests/spree/api/v2/platform/orders_spec.rb @@ -0,0 +1,30 @@ +require 'spec_helper' + +describe 'API V2 Platform Orders Spec' do + include_context 'Platform API v2' + let(:bearer_token) { { 'Authorization' => valid_authorization } } + let!(:order) { create(:vendor_order) } + + describe 'get index' do + it 'should return only parent orders' do + get '/api/v2/platform/orders', headers: bearer_token + + expect(response.status).to eq(200) + expect(json_response['data'].count).to eq(1) + expect(json_response['data'].first['id']).to eq(order.parent.id.to_s) + expect(json_response['data'].first['relationships']['vendor_orders']).to eq( + { "data" => [{ "id" => order.id.to_s, "type" => "order" }] } + ) + end + end + + describe 'get show' do + it 'should return order' do + get "/api/v2/platform/orders/#{order.id}", headers: bearer_token + + expect(response.status).to eq(200) + expect(json_response['data']['id']).to eq(order.id.to_s) + expect(json_response['data']['relationships']['vendor_orders']).to be_nil + end + end +end diff --git a/spec/requests/spree/api/v2/platform/vendors_spec.rb b/spec/requests/spree/api/v2/platform/vendors_spec.rb new file mode 100644 index 00000000..25fe5ed8 --- /dev/null +++ b/spec/requests/spree/api/v2/platform/vendors_spec.rb @@ -0,0 +1,29 @@ +require 'spec_helper' + +RSpec.describe 'API V2 Platform Vendors Spec' do + include_context 'Platform API v2' + let(:bearer_token) { { 'Authorization' => valid_authorization } } + let!(:vendor) { create(:vendor) } + + describe 'GET show' do + it 'should return vendor' do + get "/api/v2/platform/vendors/#{vendor.id}", headers: bearer_token + + expect(response.status).to eq(200) + + expect(json_response['data']['id']).to eq(vendor.id.to_s) + expect(json_response['data']['attributes']['name']).to eq(vendor.name) + expect(json_response['data']['attributes']['about_us']).to eq(vendor.about.to_plain_text) + + expect(json_response['data']['relationships']['metafields']).to eq('data' => []) + expect(json_response['data']['relationships']['policies']).to eq( + 'data' => [ + { + 'id' => vendor.policies.first.id.to_s, + 'type' => 'policy' + } + ] + ) + end + end +end diff --git a/spec/requests/spree/api/v2/storefront/account/orders_spec.rb b/spec/requests/spree/api/v2/storefront/account/orders_spec.rb new file mode 100644 index 00000000..9e74a5dc --- /dev/null +++ b/spec/requests/spree/api/v2/storefront/account/orders_spec.rb @@ -0,0 +1,124 @@ +require 'spec_helper' + +RSpec.describe 'Storefront API v2 Orders spec', type: :request do + let(:store) { Spree::Store.default } + let!(:user) { create(:user_with_addresses) } + let!(:order) { create(:order, state: 'complete', user: user, completed_at: Time.current, store: store) } + + before do + allow(Spree::Api::Config).to receive(:[]).and_call_original + allow(Spree::Api::Config).to receive(:[]).with(:api_v2_per_page_limit).and_return(2) + end + + include_context 'API v2 tokens' + + def expect_order_json(json) + expect(json).to be_present + expect(json).to have_id(order.id.to_s) + expect(json).to have_type('order') + expect(json).to have_attribute(:number).with_value(order.number) + expect(json).to have_attribute(:state).with_value(order.state) + expect(json).to have_attribute(:token).with_value(order.token) + expect(json).to have_attribute(:total).with_value(order.total.to_s) + expect(json).to have_attribute(:item_total).with_value(order.item_total.to_s) + expect(json).to have_attribute(:ship_total).with_value(order.ship_total.to_s) + expect(json).to have_attribute(:adjustment_total).with_value(order.adjustment_total.to_s) + expect(json).to have_attribute(:included_tax_total).with_value(order.included_tax_total.to_s) + expect(json).to have_attribute(:additional_tax_total).with_value(order.additional_tax_total.to_s) + expect(json).to have_attribute(:display_additional_tax_total).with_value(order.display_additional_tax_total.to_s) + expect(json).to have_attribute(:display_included_tax_total).with_value(order.display_included_tax_total.to_s) + expect(json).to have_attribute(:tax_total).with_value(order.tax_total.to_s) + expect(json).to have_attribute(:currency).with_value(order.currency.to_s) + expect(json).to have_attribute(:email).with_value(order.email) + expect(json).to have_attribute(:display_item_total).with_value(order.display_item_total.to_s) + expect(json).to have_attribute(:display_ship_total).with_value(order.display_ship_total.to_s) + expect(json).to have_attribute(:display_adjustment_total).with_value(order.display_adjustment_total.to_s) + expect(json).to have_attribute(:display_tax_total).with_value(order.display_tax_total.to_s) + expect(json).to have_attribute(:item_count).with_value(order.item_count) + expect(json).to have_attribute(:special_instructions).with_value(order.special_instructions) + expect(json).to have_attribute(:promo_total).with_value(order.promo_total.to_s) + expect(json).to have_attribute(:display_promo_total).with_value(order.display_promo_total.to_s) + expect(json).to have_attribute(:display_total).with_value(order.display_total.to_s) + expect(json).to have_attribute(:public_metadata).with_value(order.public_metadata) + expect(json).to have_relationships(:user, :line_items, :variants, :billing_address, :shipping_address, :payments, :shipments, :promotions) + end + + shared_examples 'returns included resource' do |resource| + it 'returns included resource' do + expect(json_response['included'].size).to eq 1 + expect(json_response['included'][0]).to have_type(resource) + end + end + + describe 'orders#index' do + context 'with option: include' do + let!(:vendor_order) { create(:vendor_order_with_line_items, user: user, store: store, parent: order) } + + shared_examples 'returns orders' do + it 'returns orders' do + expect(json_response['data'].size).to eq 1 + expect(json_response['data']).to be_kind_of(Array) + + expect_order_json(json_response['data'].first) + end + end + + before { get "/api/v2/storefront/account/orders?include=#{include}", headers: headers_bearer } + + context 'including vendors' do + let(:include) { 'vendors' } + + it_behaves_like 'returns orders' + + it_behaves_like 'returns included resource', 'vendor' + end + end + + context 'with splitted orders' do + let!(:order_2) { create(:order, state: 'splitted', user: user, completed_at: Time.current) } + + before { get '/api/v2/storefront/account/orders', headers: headers_bearer } + + it 'returns splitted orders' do + expect(json_response['data'].count).to eq 2 + expect(json_response['data'].pluck(:id)).to include(order_2.id.to_s) + end + end + + context 'with vendor orders' do + let(:order_2) { create(:vendor_order, state: 'complete', user: user, completed_at: Time.current) } + + before { get '/api/v2/storefront/account/orders', headers: headers_bearer } + + it 'does not return vendor orders' do + expect(json_response['data'].count).to eq 1 + expect(json_response['data'].pluck(:id)).not_to include(order_2.id.to_s) + end + end + end + + describe 'orders#show' do + let(:user) { create(:user_with_addresses) } + let(:order) { create(:order, state: 'complete', user: user, completed_at: Time.current, store: store) } + + shared_examples 'returns valid order JSON' do + it 'returns valid order JSON' do + expect_order_json(json_response['data']) + end + end + + context 'with option: include' do + let!(:vendor_order) { create(:vendor_order_with_line_items, user: user, store: store, parent: order) } + + before { get "/api/v2/storefront/account/orders/#{order.number}?include=#{include}", headers: headers_bearer } + + context 'including vendors' do + let(:include) { 'vendors' } + + it_behaves_like 'returns valid order JSON' + + it_behaves_like 'returns included resource', 'vendor' + end + end + end +end diff --git a/spec/requests/spree/api/v2/storefront/cart_spec.rb b/spec/requests/spree/api/v2/storefront/cart_spec.rb deleted file mode 100644 index 0e859c40..00000000 --- a/spec/requests/spree/api/v2/storefront/cart_spec.rb +++ /dev/null @@ -1,85 +0,0 @@ -require 'spec_helper' - -describe 'API V2 Storefront Cart Spec', type: :request do - let(:default_currency) { 'USD' } - let(:store) { Spree::Store.default } - let(:currency) { store.default_currency } - let(:user) { create(:user) } - let(:order) { create(:order, user: user, store: store, currency: currency) } - let!(:vendor) { create(:vendor) } - let!(:vendor_2) { create(:vendor) } - let(:product) { create(:product_in_stock, vendor: vendor) } - let(:product_2) { create(:product_in_stock, vendor: vendor_2) } - - include_context 'API v2 tokens' - - describe 'cart#show' do - let(:params) { {} } - let!(:line_item) { create(:line_item, order: order, product: product, variant: product.default_variant) } - - shared_examples 'showing the cart' do - before do - get '/api/v2/storefront/cart', headers: headers_bearer, params: params - end - - it_behaves_like 'returns 200 HTTP status' - it_behaves_like 'returns valid cart JSON' - end - - context 'with one vendor' do - let(:params) { { include: 'vendors,vendor_totals' } } - - it_behaves_like 'showing the cart' - - it 'includes vendor and vendor totals' do - get '/api/v2/storefront/cart', headers: headers_bearer, params: params - - expect(json_response['data']).to have_relationships(:vendors, :vendor_totals) - expect(json_response[:included][0]).to have_id(vendor.id.to_s) - expect(json_response[:included][0]).to have_type('vendor') - expect(json_response[:included][1]).to have_id(vendor.id.to_s) - expect(json_response[:included][1]).to have_type('vendor_totals') - end - end - - context 'with many vendors' do - let!(:line_item_2) { create(:line_item, order: order, product: product_2, variant: product_2.default_variant) } - let(:params) { { include: 'vendors,vendor_totals' } } - - it_behaves_like 'showing the cart' - - it 'includes many vendors and vendor totals' do - get '/api/v2/storefront/cart', headers: headers_bearer, params: params - - [vendor, vendor_2].each do |vendor| - expect(json_response['included']).to include(have_type('vendor').and have_id(vendor.id.to_s)) - expect(json_response['included']).to include(have_type('vendor_totals').and have_id(vendor.id.to_s)) - expect(json_response['included']).to include(have_type('vendor').and have_attribute(:name).with_value(vendor.name)) - expect(json_response['included']).to include(have_type('vendor_totals').and have_attribute(:name).with_value(vendor.name)) - end - end - end - - context 'includes line item vendor data' do - it_behaves_like 'showing the cart' - - it 'via variants' do - get '/api/v2/storefront/cart', params: { include: 'variants.vendor' }, headers: headers_bearer - expect(json_response[:included][0]).to have_id(vendor.id.to_s) - expect(json_response[:included][0]).to have_type('vendor') - end - - it 'via products' do - get '/api/v2/storefront/cart', params: { include: 'variants.product.vendor' }, headers: headers_bearer - expect(json_response[:included][0]).to have_id(vendor.id.to_s) - expect(json_response[:included][0]).to have_type('vendor') - end - - it 'via line_items' do - get '/api/v2/storefront/cart', params: { include: 'line_items.vendor' }, headers: headers_bearer - expect(json_response[:included][0]).to have_id(vendor.id.to_s) - expect(json_response[:included][0]).to have_type('vendor') - end - end - end -end diff --git a/spec/requests/spree/api/v2/storefront/cart_with_vendors_spec.rb b/spec/requests/spree/api/v2/storefront/cart_with_vendors_spec.rb new file mode 100644 index 00000000..5658b8c9 --- /dev/null +++ b/spec/requests/spree/api/v2/storefront/cart_with_vendors_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' + +describe 'API V2 Storefront Cart Spec', type: :request do + let!(:store) { Spree::Store.default } + let(:currency) { store.default_currency } + let(:user) { create(:user) } + let(:order) { create(:order, user: user, store: store, currency: currency) } + let!(:vendor) { create(:approved_vendor) } + let(:product) { create(:product_in_stock, stores: [store], vendor: vendor) } + let(:variant) { product.default_variant } + + include_context 'API v2 tokens' + + describe 'cart#add_item' do + let(:options) { {} } + let(:params) { { variant_id: variant.id, quantity: 5, options: options, include: 'variants,line_items.vendor' } } + let(:execute) { post '/api/v2/storefront/cart/add_item', params: params, headers: headers_order_token } + + before { execute } + + it_behaves_like 'returns 200 HTTP status' + it_behaves_like 'returns valid cart JSON' + + it 'adds vendor item' do + order.reload + + expect(order.line_items.count).to eq(1) + expect(order.line_items.last.variant).to eq(variant) + expect(order.line_items.last.vendor).to eq(vendor) + expect(order.line_items.last.quantity).to eq(5) + expect(json_response['included']).to include(have_type('variant').and(have_id(variant.id.to_s))) + expect(json_response['included']).to include(have_type('vendor').and(have_id(vendor.id.to_s))) + end + end + + describe 'cart#show' do + let!(:line_item) { create(:line_item, order: order, variant: variant, quantity: 5) } + + let(:params) { { include: 'line_items.vendor' } } + + let(:execute) { get '/api/v2/storefront/cart', headers: headers_order_token, params: params } + + before { execute } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns valid cart JSON' do + expect(json_response['included']).to include(have_type('vendor').and(have_id(vendor.id.to_s))) + end + + context 'including vendors directly' do + let(:params) { { include: 'vendors' } } + + it 'returns valid cart JSON' do + expect(json_response['included']).to include(have_type('vendor').and(have_id(vendor.id.to_s))) + end + end + end +end diff --git a/spec/requests/spree/api/v2/storefront/checkout_with_vendors_spec.rb b/spec/requests/spree/api/v2/storefront/checkout_with_vendors_spec.rb new file mode 100644 index 00000000..12021e90 --- /dev/null +++ b/spec/requests/spree/api/v2/storefront/checkout_with_vendors_spec.rb @@ -0,0 +1,362 @@ +require 'spec_helper' + +describe 'API V2 Storefront Checkout Spec', type: :request do + let!(:store) { Spree::Store.default } + let!(:vendor) { create(:approved_vendor) } + + let(:currency) { store.default_currency } + let(:user) { create(:user) } + let(:order) { create(:order, user: user, store: store, currency: currency) } + let(:payment) { create(:payment, amount: order.total, order: order) } + let(:shipment) { create(:shipment, order: order) } + + let(:address) do + { + firstname: 'John', + lastname: 'Doe', + address1: '7735 Old Georgetown Road', + city: 'Bethesda', + phone: '3014445002', + zipcode: '20814', + state_id: state.id, + country_iso: country.iso + } + end + + let(:payment_source_attributes) do + { + gateway_payment_profile_id: 'BGS-123', + gateway_customer_profile_id: 'BGS-123', + month: 1.month.from_now.month, + year: 1.month.from_now.year, + name: 'Spree Commerce', + last_digits: '1111' + } + end + let(:payment_params) do + { + order: { + payments_attributes: [ + { + payment_method_id: payment_method.id + } + ] + }, + payment_source: { + payment_method.id.to_s => payment_source_attributes + } + } + end + + include_context 'API v2 tokens' + + describe 'checkout#shipping_rates' do + let(:execute) { get '/api/v2/storefront/checkout/shipping_rates', headers: headers } + + let(:country) { store.default_country } + let(:zone) { create(:zone) } + let(:shipping_method) { create(:shipping_method, vendor: vendor) } + let(:shipping_method_2) { create(:shipping_method, vendor: vendor) } + let(:address) { create(:address, country: country) } + + let(:shipment) { order.shipments.first } + let(:shipping_rate) { shipment.selected_shipping_rate } + let(:shipping_rate_2) { shipment.shipping_rates.where(selected: false).first } + + shared_examples 'returns a list of shipments with shipping rates' do + before do + order.shipping_address = address + order.save! + zone.countries << country + shipping_method.zones = [zone] + shipping_method_2.zones = [zone] + order.create_proposed_shipments + execute + order.reload + end + + it_behaves_like 'returns 200 HTTP status' + + it 'returns valid shipments JSON' do + expect(json_response['data']).not_to be_empty + expect(json_response['data'].size).to eq(order.shipments.count) + expect(json_response['data'][0]).to have_id(shipment.id.to_s) + expect(json_response['data'][0]).to have_type('shipment') + expect(json_response['data'][0]).to have_relationships(:shipping_rates) + expect(json_response['included']).to be_present + expect(json_response['included'].size).to eq(shipment.shipping_rates.count + 2) + [{ shipping_method: shipping_method, shipping_rate: shipping_rate }, + { shipping_method: shipping_method_2, shipping_rate: shipping_rate_2 }].each do |shipping| + expect(json_response['included']).to include(have_type('shipping_rate').and(have_attribute(:name).with_value(shipping[:shipping_method].name))) + expect(json_response['included']).to include(have_type('shipping_rate').and(have_attribute(:shipping_method_id).with_value(shipping[:shipping_method].id.to_s))) + expect(json_response['included']).to include(have_type('shipping_rate').and(have_id(shipping[:shipping_rate].id.to_s))) + expect(json_response['included']).to include(have_type('shipping_rate').and(have_attribute(:cost).with_value(shipping[:shipping_rate].cost.to_s))) + expect(json_response['included']).to include(have_type('shipping_rate').and(have_attribute(:tax_amount).with_value(shipping[:shipping_rate].tax_amount.to_s))) + expect(json_response['included']).to include(have_type('shipping_rate').and(have_attribute(:selected).with_value(shipping[:shipping_rate].selected))) + expect(json_response['included']).to include(have_type('shipping_rate').and(have_attribute(:final_price).with_value(shipping[:shipping_rate].final_price.to_s))) + expect(json_response['included']).to include(have_type('shipping_rate').and(have_attribute(:free).with_value(shipping[:shipping_rate].free?))) + expect(json_response['included']).to include(have_type('shipping_rate').and(have_attribute(:display_final_price).with_value(shipping[:shipping_rate].display_final_price.to_s))) + expect(json_response['included']).to include(have_type('shipping_rate').and(have_attribute(:display_cost).with_value(shipping[:shipping_rate].display_cost.to_s))) + expect(json_response['included']).to include(have_type('shipping_rate').and(have_attribute(:display_tax_amount).with_value(shipping[:shipping_rate].display_tax_amount.to_s))) + expect(json_response['included']).to include(have_type('shipping_rate').and(have_relationship(:shipping_method).with_data({ 'id' => shipping[:shipping_method].id.to_s, 'type' => 'shipping_method' }))) + end + expect(json_response['included']).to include(have_type('stock_location').and(have_id(shipment.stock_location_id.to_s))) + expect(json_response['included']).to include(have_type('stock_location').and(have_attribute(:name).with_value(shipment.stock_location.name))) + end + end + + context 'as a guest user' do + include_context 'creates vendor guest order with guest token' + + it_behaves_like 'returns a list of shipments with shipping rates' + end + + context 'as a signed in user' do + include_context 'vendor order with a physical line item' + + it_behaves_like 'returns a list of shipments with shipping rates' + end + end + + describe 'checkout#select_shipping_method' do + let(:headers) { headers_bearer } + let(:params) do + { + shipping_method_id: shipping_method_2.id + } + end + let(:zone) { create(:zone_with_country) } + + let(:country) { create(:country) } + let(:order) { create(:order, store: store, user: user, ship_address: create(:address, user: user, country: country)) } + let!(:line_item) { create(:vendor_line_item, order: order, vendor: vendor) } + let(:shipping_category) { order.products.first.shipping_category } + let!(:shipping_method) do + create(:shipping_method, vendor: vendor, zones: [zone], shipping_categories: [shipping_category]) do |shipping_method| + shipping_method.calculator.preferred_amount = 10 + shipping_method.calculator.save + end + end + let!(:shipping_method_2) do + create(:shipping_method, vendor: vendor, zones: [zone], shipping_categories: [shipping_category]) do |shipping_method| + shipping_method.calculator.preferred_amount = 15 + shipping_method.calculator.save + end + end + let!(:shipping_method_3) do + create(:shipping_method, vendor: vendor, zones: [create(:zone)], shipping_categories: [shipping_category]) do |shipping_method| + shipping_method.calculator.preferred_amount = 5 + shipping_method.calculator.save + end + end + let(:shipment) { order.shipments.first } + let(:selected_shipping_rate) { shipment.selected_shipping_rate } + let(:execute) { patch '/api/v2/storefront/checkout/select_shipping_method?include=shipments.vendor', headers: headers, params: params } + + before do + zone.countries << country + # making sure our store is in the geo zone supported by shipping method + store.update(checkout_zone: zone) + # generate shipping rates + get '/api/v2/storefront/checkout/shipping_rates', headers: headers + end + + context 'one shipment' do + context 'valid shipping method' do + before { execute } + + it_behaves_like 'returns 200 HTTP status' + + it 'sets selected shipping method for shipment' do + expect(json_response['included']).to include(have_type('shipment').and(have_id(shipment.id.to_s))) + expect(json_response['included']).to include(have_type('vendor').and(have_id(vendor.id.to_s))) + expect(json_response['included'][1]).to have_relationship(:selected_shipping_rate).with_data({ 'id' => selected_shipping_rate.id.to_s, + 'type' => 'shipping_rate' }) + expect(selected_shipping_rate.shipping_method).to eq(shipping_method_2) + end + end + + context 'missing shipping method' do + let(:params) do + { + shipping_method_id: shipping_method_3.id + } + end + + before { execute } + + it_behaves_like 'returns 422 HTTP status' + end + end + end + + orders_requiring_delivery = ['vendor order with a physical line item', 'vendor order with a physical and digital line item'].freeze + + orders_requiring_delivery.each do |physical_goods_context| + describe "full checkout flow (#{physical_goods_context})" do + let!(:country) { create(:country) } + let(:state) { create(:state, country: country) } + let!(:shipping_method) do + create(:shipping_method, vendor: vendor).tap do |shipping_method| + shipping_method.zones = [zone] + end + end + let!(:zone) { create(:zone) } + let!(:zone_member) { create(:zone_member, zone: zone, zoneable: country) } + let!(:payment_method) { create(:credit_card_payment_method, stores: [store]) } + + let(:customer_params) do + { + order: { + email: 'new@customer.org', + bill_address_attributes: address, + ship_address_attributes: address + } + } + end + + let(:shipment_params) do + { + order: { + shipments_attributes: [ + { selected_shipping_rate_id: shipping_rate_id, id: shipment_id } + ] + } + } + end + + let(:shipping_rate_id) do + json_response['data'].first['relationships']['shipping_rates']['data'].first['id'] + end + let(:shipment_id) { json_response['data'].first['id'] } + + shared_examples 'transitions through checkout from start to finish' do + before do + zone.countries << country + shipping_method.zones = [zone] + end + + it 'completes checkout' do + # we need to set customer information (email, billing & shipping address) + patch '/api/v2/storefront/checkout', params: customer_params, headers: headers + expect(response.status).to eq(200) + + # getting back shipping rates + get '/api/v2/storefront/checkout/shipping_rates', headers: headers + expect(response.status).to eq(200) + + # selecting shipping method + patch '/api/v2/storefront/checkout', params: shipment_params, headers: headers + expect(response.status).to eq(200) + + # getting back list of available payment methods + get '/api/v2/storefront/checkout/payment_methods', headers: headers + expect(response.status).to eq(200) + expect(json_response['data'].first['id']).to eq(payment_method.id.to_s) + + # creating a CC for selected payment method + patch '/api/v2/storefront/checkout', params: payment_params, headers: headers + expect(response.status).to eq(200) + + # complete the checkout + patch '/api/v2/storefront/checkout/complete', headers: headers + expect(response.status).to eq(200) + expect(order.reload.completed_at).not_to be_nil + expect(order.state).to eq('complete') + expect(order.payments.valid.first.payment_method).to eq(payment_method) + end + end + + context 'as a guest user' do + include_context 'creates vendor guest order with guest token' + + it_behaves_like 'transitions through checkout from start to finish' + end + + context 'as a signed in user' do + include_context physical_goods_context + + it_behaves_like 'transitions through checkout from start to finish' + end + end + end + + describe 'full checkout flow digital' do + let!(:country) { create(:country) } + let(:state) { create(:state, country: country) } + let!(:shipping_method) do + create(:shipping_method, vendor: vendor).tap do |shipping_method| + shipping_method.zones = [zone] + end + end + let!(:zone) { create(:zone) } + let!(:zone_member) { create(:zone_member, zone: zone, zoneable: country) } + let!(:payment_method) { create(:credit_card_payment_method, stores: [store]) } + + let(:customer_params) do + { + order: { + email: 'new@customer.org', + bill_address_attributes: address, + ship_address_attributes: address + } + } + end + + let(:shipment_params) do + { + order: { + shipments_attributes: [ + { selected_shipping_rate_id: shipping_rate_id, id: shipment_id } + ] + } + } + end + + let(:shipping_rate_id) do + json_response['data'].first['relationships']['shipping_rates']['data'].first['id'] + end + let(:shipment_id) { json_response['data'].first['id'] } + + shared_examples 'transitions through checkout from start to finish' do + before do + zone.countries << country + shipping_method.zones = [zone] + end + + it 'completes checkout skipping delivery stage' do + # we need to set customer information (email, billing & shipping address) + patch '/api/v2/storefront/checkout', params: customer_params, headers: headers + expect(response.status).to eq(200) + + # getting back list of available payment methods + get '/api/v2/storefront/checkout/payment_methods', headers: headers + expect(response.status).to eq(200) + expect(json_response['data'].first['id']).to eq(payment_method.id.to_s) + + # creating a CC for selected payment method + patch '/api/v2/storefront/checkout', params: payment_params, headers: headers + expect(response.status).to eq(200) + + # complete the checkout + patch '/api/v2/storefront/checkout/complete', headers: headers + expect(response.status).to eq(200) + expect(order.reload.completed_at).not_to be_nil + expect(order.state).to eq('complete') + expect(order.payments.valid.first.payment_method).to eq(payment_method) + end + end + + context 'as a guest user' do + include_context 'creates vendor guest order with guest token' + + it_behaves_like 'transitions through checkout from start to finish' + end + + context 'as a signed in user' do + include_context 'vendor order with a digital line item' + + it_behaves_like 'transitions through checkout from start to finish' + end + end +end diff --git a/spec/requests/spree/api/v2/storefront/products_spec.rb b/spec/requests/spree/api/v2/storefront/products_spec.rb deleted file mode 100644 index 19652f80..00000000 --- a/spec/requests/spree/api/v2/storefront/products_spec.rb +++ /dev/null @@ -1,96 +0,0 @@ -require 'spec_helper' - -describe 'API V2 Storefront Products Spec', type: :request do - let(:store) { Spree::Store.default } - let!(:products) { create_list(:product, 5, stores: [store]) } - let!(:vendor) { create(:vendor) } - let!(:vendor_2) { create(:vendor) } - - before do - vendors = Spree::Vendor.all.to_a - products.each do |product| - product.vendor = vendors.sample - product.save! - end - end - - describe 'products#index' do - context 'with no params' do - before { get '/api/v2/storefront/products' } - - it_behaves_like 'returns 200 HTTP status' - - it 'returns all products' do - expect(json_response['data'].count).to eq Spree::Product.available.count - expect(json_response['data'].first).to have_type('product') - end - end - - context 'with specified vendor ids' do - before { vendor.products << create(:product) } - before { vendor_2.products << create(:product) } - before { get "/api/v2/storefront/products?filter[vendor_ids]=#{vendor.id}&include=vendor" } - - it_behaves_like 'returns 200 HTTP status' - - it 'returns products with specified ids' do - expect(json_response['data'].count).to eq Spree::Product.available.where(vendor_id: vendor.id).count - expect(json_response['included'].first).to have_id(vendor.id.to_s) - expect(json_response['included'].first).to have_attribute(:name).with_value(vendor.name) - expect(json_response['included'].first).to have_attribute(:about_us).with_value(vendor.about_us) - end - end - - context 'with specified multiple filters' do - before { get "/api/v2/storefront/products?filter[skus]=#{products.first.sku}&filter[vendor_ids]=#{products.first.vendor_id}&include=vendor" } - - it_behaves_like 'returns 200 HTTP status' - - it 'returns products with specified SKU and Vendor ID' do - expect(json_response['data'].count).to eq 1 - expect(json_response['data'].first).to have_id(products.first.id.to_s) - expect(json_response['included'].first).to have_id(products.first.vendor_id.to_s) - expect(json_response['included'].first).to have_attribute(:name).with_value(products.first.vendor.name) - expect(json_response['included'].first).to have_attribute(:about_us).with_value(products.first.vendor.about_us) - end - end - end - - describe 'products#show' do - let(:product) { products.first } - - context 'with existing product' do - before { get "/api/v2/storefront/products/#{product.slug}?include=vendor" } - - it_behaves_like 'returns 200 HTTP status' - - it 'returns a valid JSON response' do - expect(json_response['data']).to have_id(product.id.to_s) - - expect(json_response['data']).to have_type('product') - - expect(json_response['data']).to have_attribute(:name).with_value(product.name) - expect(json_response['data']).to have_attribute(:description).with_value(product.description) - expect(json_response['data']).to have_attribute(:price).with_value(product.price.to_s) - expect(json_response['data']).to have_attribute(:currency).with_value(product.currency) - expect(json_response['data']).to have_attribute(:display_price).with_value(product.display_price.to_s) - expect(json_response['data']).to have_attribute(:available_on).with_value(product.available_on.as_json) - expect(json_response['data']).to have_attribute(:slug).with_value(product.slug) - expect(json_response['data']).to have_attribute(:meta_description).with_value(product.meta_description) - expect(json_response['data']).to have_attribute(:meta_keywords).with_value(product.meta_keywords) - expect(json_response['data']).to have_attribute(:updated_at).with_value(product.updated_at.as_json) - expect(json_response['data']).to have_attribute(:purchasable).with_value(product.purchasable?) - expect(json_response['data']).to have_attribute(:in_stock).with_value(product.in_stock?) - expect(json_response['data']).to have_attribute(:backorderable).with_value(product.backorderable?) - - expect(json_response['data']).to have_relationships( - :variants, :option_types, :product_properties, :default_variant, :vendor - ) - - expect(json_response['included'].first).to have_id(product.vendor_id.to_s) - expect(json_response['included'].first).to have_attribute(:name).with_value(product.vendor.name) - expect(json_response['included'].first).to have_attribute(:about_us).with_value(product.vendor.about_us) - end - end - end -end diff --git a/spec/requests/spree/api/v2/storefront/products_with_vendors_spec.rb b/spec/requests/spree/api/v2/storefront/products_with_vendors_spec.rb new file mode 100644 index 00000000..0750bd22 --- /dev/null +++ b/spec/requests/spree/api/v2/storefront/products_with_vendors_spec.rb @@ -0,0 +1,134 @@ +require 'spec_helper' + +describe 'API V2 Storefront Products Spec', type: :request do + let!(:store) { Spree::Store.default } + let!(:products) { create_list(:product, 5, stores: [store]) } + let(:taxonomy) { create(:taxonomy, store: store) } + let!(:taxon) { taxonomy.root } + let(:product_with_taxon) { create(:product, taxons: [taxon], stores: [store]) } + let(:product_with_name) { create(:product, name: 'Test Product', stores: [store]) } + let(:product_with_price) { create(:product, price: 13.44, stores: [store]) } + let!(:option_type) { create(:option_type) } + let!(:option_value) { create(:option_value, option_type: option_type) } + let(:product_with_option) { create(:product, option_types: [option_type], stores: [store]) } + let!(:variant) { create(:variant, product: product_with_option, option_values: [option_value]) } + let(:product) { create(:product, stores: [store]) } + let!(:deleted_product) { create(:product, deleted_at: Time.current - 1.day, stores: [store]) } + let!(:discontinued_product) { create(:product, discontinue_on: Time.current - 1.day, stores: [store]) } + let!(:not_available_product) { create(:product, available_on: nil, stores: [store]) } + let!(:in_stock_product) { create(:product_in_stock, stores: [store]) } + let!(:not_backorderable_product) { create(:product_in_stock, :without_backorder, stores: [store]) } + let!(:property) { create(:property) } + let!(:new_property) { create(:property) } + let!(:product_with_property) { create(:product, stores: [store]) } + let!(:product_property) { create(:product_property, property: new_property, product: product_with_property, value: 'Some Value') } + let!(:product_property2) { create(:product_property, property: property, product: product_with_property, value: 'Some Value 2') } + let(:product_with_vendor_a) { create(:product, vendor: create(:approved_vendor), stores: [store]) } + let(:product_with_vendor_b) { create(:product, vendor: create(:approved_vendor), stores: [store]) } + + before { Spree::Api::Config[:api_v2_per_page_limit] = 4 } + + describe 'products#index' do + context 'include vendors' do + before { product_with_vendor_a } + + it 'returns products with vendors' do + get '/api/v2/storefront/products?include=vendor' + + expect(response).to be_successful + expect(json_response['included'].count).to eq(1) + expect(json_response['included'].first['type']).to eq('vendor') + expect(json_response['included'].first['id']).to eq(product_with_vendor_a.vendor.id.to_s) + end + end + + context 'with specified vendor_ids' do + before { get "/api/v2/storefront/products?filter[vendor_ids]=#{product_with_vendor_a.vendor_id},#{product_with_vendor_b.vendor_id}" } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns products with specified vendor_ids' do + expect(json_response['data']).not_to be_nil + expect(json_response['data'].count).to eq(2) + expect(json_response['data'].map(&:first).map(&:last)).to contain_exactly(product_with_vendor_a.id.to_s, product_with_vendor_b.id.to_s) + end + end + + context 'with multiple specified options' do + let!(:color) { Spree::OptionType.find_by(name: 'color') || create(:option_type, :color) } + let!(:green_color) { color.option_values.find_by(name: 'green') || create(:option_value, option_type: color, name: 'green') } + let!(:white_color) { color.option_values.find_by(name: 'white') || create(:option_value, option_type: color, name: 'white') } + + let!(:size) { Spree::OptionType.find_by(name: 'size') || create(:option_type, :size) } + let!(:s_size) { size.option_values.find_by(name: 's') || create(:option_value, option_type: size, name: 's') } + let!(:m_size) { size.option_values.find_by(name: 'm') || create(:option_value, option_type: size, name: 'm') } + + let(:product_1) { create(:product, option_types: [color, size], stores: [store]) } + let!(:variant_1) { create(:variant, product: product_1, option_values: [white_color, m_size]) } + + let(:product_2) { create(:product, option_types: [color, size], stores: [store]) } + let!(:variant_2_1) { create(:variant, product: product_2, option_values: [green_color, s_size]) } + let!(:variant_2_2) { create(:variant, product: product_2, option_values: [white_color, s_size]) } + + context 'for filters with products' do + let(:options_filter) do + [ + "filter[options][#{color.name}]=#{white_color.name}", + "filter[options][#{size.name}]=#{m_size.name}" + ].join('&') + end + + before { get "/api/v2/storefront/products?#{options_filter}&include=option_types,variants.option_values" } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns products with specified options' do + expect(json_response['data']).to include(have_id(product_1.id.to_s)) + expect(json_response['data']).not_to include(have_id(product_2.id.to_s)) + + expect(json_response['included']).to include(have_type('option_type').and(have_attribute(:name).with_value(color.name))) + expect(json_response['included']).to include(have_type('option_value').and(have_attribute(:name).with_value(white_color.name))) + + expect(json_response['included']).to include(have_type('option_type').and(have_attribute(:name).with_value(size.name))) + expect(json_response['included']).to include(have_type('option_value').and(have_attribute(:name).with_value(m_size.name))) + end + end + end + + context 'with specified multiple filters' do + before { get "/api/v2/storefront/products?filter[name]=#{product_with_name.name}&filter[price]=#{product_with_name.price.to_f - 0.02},#{product_with_name.price.to_f + 0.02}" } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns products with specified name and price' do + expect(json_response['data'].count).to eq 1 + expect(json_response['data'].first).to have_id(product_with_name.id.to_s) + end + end + + # Regression test for SD-1439 ambiguous column name: count_on_hand + context 'with multiple params' do + before do + get '/api/v2/storefront/products?filter[backorderable]=true'\ + '&filter[ids]=130'\ + '&filter[in_stock]=true'\ + '&filter[name]=rails'\ + '&filter[options][tshirt-color]=Red'\ + '&filter[price]=10,100'\ + '&filter[properties][brand-name]=alpha'\ + '&filter[purchasable]=true'\ + '&filter[show_deleted]=true'\ + '&filter[show_discontinued]=true'\ + '&filter[skus]=SKU-123,SKU-345'\ + "&filter[taxons]=#{SecureRandom.uuid},#{SecureRandom.uuid},#{SecureRandom.uuid},#{SecureRandom.uuid},#{SecureRandom.uuid}"\ + "&filter[vendor_ids]=#{SecureRandom.uuid},#{SecureRandom.uuid},#{SecureRandom.uuid},#{SecureRandom.uuid}"\ + '&include=default_variant,variants,option_types,product_properties,taxons,images,primary_variant,vendor'\ + '&page=1'\ + '&per_page=25'\ + '&sort=-updated_at,price,-name,created_at,-available_on,sku' + end + + it_behaves_like 'returns 200 HTTP status' + end + end +end diff --git a/spec/requests/spree/api/v2/storefront/vendors_spec.rb b/spec/requests/spree/api/v2/storefront/vendors_spec.rb index 4dc6ff47..66e240ec 100644 --- a/spec/requests/spree/api/v2/storefront/vendors_spec.rb +++ b/spec/requests/spree/api/v2/storefront/vendors_spec.rb @@ -1,117 +1,79 @@ require 'spec_helper' -describe 'API V2 Storefront Vendor Spec', type: :request do - - let!(:vendor_image) { create(:vendor_image) } - let!(:vendor) { create(:active_vendor, name: 'vendor', image: vendor_image) } - let!(:product) { create(:product, vendor: vendor) } - let!(:vendors) { - create_list(:active_vendor_list, 30)} - +RSpec.describe 'API V2 Storefront Vendor Spec', type: :request do describe 'vendors#show' do - context 'with invalid slug param' do + let!(:vendor) { create(:approved_vendor, :with_logo, :with_cover_photo, about: 'About section') } + let!(:product) { create(:product, vendor: vendor) } + + context 'with invalid vendor id' do before { get '/api/v2/storefront/vendors/vendor1' } it_behaves_like 'returns 404 HTTP status' end - context 'with valid slug param' do - before { get "/api/v2/storefront/vendors/#{vendor.slug}" } + context 'with valid vendor id' do + let(:response_data) { json_response['data'] } - it_behaves_like 'returns 200 HTTP status' + before { get "/api/v2/storefront/vendors/#{vendor.id}" } - it 'returns one vendor' do - json_response = JSON.parse(response.body) - expect(json_response.count).to eq 1 - expect(json_response['data']['type']).to eq('vendor') - end + it_behaves_like 'returns 200 HTTP status' - it 'does not return included' do - json_response = JSON.parse(response.body) - expect(json_response.keys).to contain_exactly('data') + it 'returns vendor valid JSON response' do + expect(response_data['id']).to eq(vendor.id.to_s) + expect(response_data['type']).to eq('vendor') + expect(response_data['attributes']).to include( + 'name' => vendor.name, + 'email' => vendor.contact_person_email, + 'about_us' => 'About section' + ) + expect(response_data['attributes']['logo_url']).to be_present + expect(response_data['attributes']['cover_photo_url']).to be_present end end - context 'with products and images included' do - before { get "/api/v2/storefront/vendors/#{vendor.slug}?include=products,image" } + context 'with vendor slug' do + before { get "/api/v2/storefront/vendors/#{vendor.slug}" } it_behaves_like 'returns 200 HTTP status' - - it 'returns product information' do - json_response = JSON.parse(response.body) - expect(json_response.keys).to contain_exactly('data', 'included') - expect(json_response['included'].first['id']).to eq(product.id.to_s) - end - - it 'returns image information' do - json_response = JSON.parse(response.body) - expect(json_response.keys).to contain_exactly('data', 'included') - - expect(json_response['included'].second['id']).to eq(vendor_image.id.to_s) - end end end - describe 'vendors#index' do - context 'returns vendors list' do - - it 'must return a list of vendor paged' do - get "/api/v2/storefront/vendors" - expect(response).to have_http_status(:ok) - json_response = JSON.parse(response.body) - expect(json_response) - expect(json_response['data'].count).to eq (25) - end - - it 'can request different pages' do - get "/api/v2/storefront/vendors?page=2" - expect(response).to have_http_status(:ok) - json_response = JSON.parse(response.body) - expect(json_response) - expect(json_response['data'].count).to eq (6) - - end - - it 'can control paging size' do - get "/api/v2/storefront/vendors?page=2&per_page=10" - expect(response).to have_http_status(:ok) - json_response = JSON.parse(response.body) - expect(json_response) - expect(json_response['data'].count).to eq (10) - end + let!(:vendor) { create(:approved_vendor, :with_logo, :with_cover_photo, about: 'About section') } + let!(:vendors) { create_list(:approved_vendor, 10) } + let(:params) { {} } + before { get '/api/v2/storefront/vendors', params: params } - it 'returns data type vendor' do - get "/api/v2/storefront/vendors" - json_response = JSON.parse(response.body) - expect(json_response) - expect(json_response['data'][0]['type']).to eq('vendor') - end - - it 'does not return included' do - get "/api/v2/storefront/vendors" - json_response = JSON.parse(response.body) - expect(json_response.keys).to contain_exactly('data', "links", "meta") - end - end - - context 'with products and images included' do - before { get "/api/v2/storefront/vendors?include=products,image" } + context 'returns vendors list' do + let(:vendor_data) { json_response['data'].find { |data| data['id'] == vendor.id.to_s } } it_behaves_like 'returns 200 HTTP status' - it 'returns product information' do - json_response = JSON.parse(response.body) - expect(json_response.keys).to contain_exactly('data', 'included', "links", "meta") - expect(json_response['included'].first['id']).to eq(product.id.to_s) + it 'with all vendors' do + expect(json_response['data'].count).to eq (11) end - it 'returns image information' do - json_response = JSON.parse(response.body) - expect(json_response.keys).to contain_exactly('data', 'included',"links", "meta") - - expect(json_response['included'].second['id']).to eq(vendor_image.id.to_s) + it 'returns vendor valid JSON response' do + expect(vendor_data['id']).to eq(vendor.id.to_s) + expect(vendor_data['type']).to eq('vendor') + expect(vendor_data['attributes']).to include( + 'name' => vendor.name, + 'email' => vendor.contact_person_email, + 'about_us' => 'About section' + ) + expect(vendor_data['attributes']['logo_url']).to be_present + expect(vendor_data['attributes']['cover_photo_url']).to be_present + + expect(vendor_data['relationships']['metafields']).to eq('data' => []) + expect(vendor_data['relationships']['policies']).to eq( + 'data' => [ + { + 'id' => vendor.policies.first.id.to_s, + 'type' => 'policy' + } + ] + ) end end end diff --git a/spec/serializers/spree/api/v2/platform/vendor_serializer_spec.rb b/spec/serializers/spree/api/v2/platform/vendor_serializer_spec.rb new file mode 100644 index 00000000..8a6c1dc1 --- /dev/null +++ b/spec/serializers/spree/api/v2/platform/vendor_serializer_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe Spree::Api::V2::Platform::VendorSerializer do + include_context 'API v2 serializers params' + + subject { described_class.new(vendor, params: serializer_params).serializable_hash } + + let(:vendor) { create(:vendor) } + + it 'returns the vendor name' do + expect(subject.dig(:data, :attributes, :name)).to eq(vendor.name) + end + + it 'returns the slug' do + expect(subject.dig(:data, :attributes, :slug)).to eq(vendor.slug) + end + + it 'returns the contact person email' do + expect(subject.dig(:data, :attributes, :contact_person_email)).to eq(vendor.contact_person_email) + end + + it 'returns the public metadata' do + expect(subject.dig(:data, :attributes, :public_metadata)).to eq(vendor.public_metadata) + end +end \ No newline at end of file diff --git a/spec/serializers/spree/v2/storefront/order_serializer_spec.rb b/spec/serializers/spree/v2/storefront/order_serializer_spec.rb new file mode 100644 index 00000000..49d16dca --- /dev/null +++ b/spec/serializers/spree/v2/storefront/order_serializer_spec.rb @@ -0,0 +1,92 @@ +require 'spec_helper' + +describe Spree::V2::Storefront::OrderSerializer do + include_context 'API v2 serializers params' + + subject { described_class.new(order).serializable_hash } + + let!(:parent_order) { create(:order, state: 'complete', completed_at: Time.current, store: store, parent: nil, vendor: nil) } + let!(:vendor_order) { create(:vendor_order_with_line_items, store: store, parent: parent_order) } + + describe 'associations' do + describe '#line_items' do + context 'for parent order' do + let(:order) { parent_order } + + it 'returns all line items' do + expect(subject[:data][:relationships][:line_items][:data].count).to eq 1 + expect(subject[:data][:relationships][:line_items][:data][0][:id]).to eq vendor_order.line_items.take.id.to_s + end + end + + context 'for vendor order' do + let(:order) { vendor_order } + + it 'returns all line items' do + expect(subject[:data][:relationships][:line_items][:data].count).to eq 1 + expect(subject[:data][:relationships][:line_items][:data][0][:id]).to eq vendor_order.line_items.take.id.to_s + end + end + end + + describe '#variants' do + context 'for parent order' do + let(:order) { parent_order } + + it 'returns all variants' do + expect(subject[:data][:relationships][:variants][:data].count).to eq 1 + expect(subject[:data][:relationships][:variants][:data][0][:id]).to eq vendor_order.variants.take.id.to_s + end + end + + context 'for vendor order' do + let(:order) { vendor_order } + + it 'returns all variants' do + expect(subject[:data][:relationships][:variants][:data].count).to eq 1 + expect(subject[:data][:relationships][:variants][:data][0][:id]).to eq vendor_order.variants.take.id.to_s + end + end + end + + describe '#shipments' do + context 'for parent order' do + let(:order) { parent_order } + + it 'returns all shipments' do + expect(subject[:data][:relationships][:shipments][:data].count).to eq 1 + expect(subject[:data][:relationships][:shipments][:data][0][:id]).to eq vendor_order.shipments.take.id.to_s + end + end + + context 'for vendor order' do + let(:order) { vendor_order } + + it 'returns all shipments' do + expect(subject[:data][:relationships][:shipments][:data].count).to eq 1 + expect(subject[:data][:relationships][:shipments][:data][0][:id]).to eq vendor_order.shipments.take.id.to_s + end + end + end + + describe '#vendors' do + context 'for parent order' do + let(:order) { parent_order } + + it 'returns all vendors' do + expect(subject[:data][:relationships][:vendors][:data].count).to eq 1 + expect(subject[:data][:relationships][:vendors][:data][0][:id]).to eq vendor_order.vendors.take.id.to_s + end + end + + context 'for vendor order' do + let(:order) { vendor_order } + + it 'returns all vendors' do + expect(subject[:data][:relationships][:vendors][:data].count).to eq 1 + expect(subject[:data][:relationships][:vendors][:data][0][:id]).to eq vendor_order.vendors.take.id.to_s + end + end + end + end +end diff --git a/spec/serializers/spree/v2/storefront/stock_location_serializer_spec.rb b/spec/serializers/spree/v2/storefront/stock_location_serializer_spec.rb deleted file mode 100644 index 6528bc09..00000000 --- a/spec/serializers/spree/v2/storefront/stock_location_serializer_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -require 'spec_helper' - -describe Spree::V2::Storefront::StockLocationSerializer do - let(:vendor) { create(:vendor, name: 'Test', about_us: 'Hello World') } - let(:stock_location) { create(:stock_location, vendor: vendor) } - - subject { described_class.new(stock_location) } - - it { expect(subject.serializable_hash).to be_kind_of(Hash) } - it do - expect(subject.serializable_hash).to eq( - { - data: { - id: stock_location.id.to_s, - type: :stock_location, - attributes: { - name: stock_location.name - }, - relationships: { - vendor: { - data: { - id: vendor.id.to_s, - type: :vendor - } - } - } - } - } - ) - end -end - diff --git a/spec/serializers/spree/v2/storefront/taxon_serializer_spec.rb b/spec/serializers/spree/v2/storefront/taxon_serializer_spec.rb new file mode 100644 index 00000000..9bef6102 --- /dev/null +++ b/spec/serializers/spree/v2/storefront/taxon_serializer_spec.rb @@ -0,0 +1,56 @@ +require 'spec_helper' + +RSpec.describe Spree::V2::Storefront::TaxonSerializer do + include_context 'API v2 serializers params' + + subject { described_class.new(taxon.reload, params: serializer_params.merge({ include_products: include_products })).serializable_hash } + + let(:taxon) { create(:taxon) } + + let(:include_products) { false } + + it { expect(subject).to be_kind_of(Hash) } + + describe 'products' do + let(:include_products) { true } + + let!(:active_product_from_approved_vendor) { create(:product, taxons: [taxon], vendor: create(:vendor)) } + let!(:active_product_from_unapproved_vendor) { create(:product, taxons: [taxon], vendor: create(:vendor, state: :onboarding)) } + let!(:inactive_product_from_approved_vendor) { create(:product, taxons: [taxon], vendor: create(:vendor), status: :draft) } + let!(:inactive_product_from_unapproved_vendor) do + create(:product, taxons: [taxon], vendor: create(:vendor, state: :onboarding), status: :draft) + end + let!(:active_product_without_vendor) { create(:product, taxons: [taxon], vendor: nil) } + let!(:inactive_product_without_vendor) { create(:product, taxons: [taxon], vendor: nil, status: :draft) } + + it 'returns all products' do + expect(subject[:data][:relationships][:products][:data].count).to eq(6) + expect(subject[:data][:relationships][:products][:data]).to match_array( + [ + { type: :product, id: active_product_from_approved_vendor.id.to_s }, + { type: :product, id: active_product_from_unapproved_vendor.id.to_s }, + { type: :product, id: inactive_product_from_approved_vendor.id.to_s }, + { type: :product, id: inactive_product_from_unapproved_vendor.id.to_s }, + { type: :product, id: active_product_without_vendor.id.to_s }, + { type: :product, id: inactive_product_without_vendor.id.to_s } + ] + ) + end + end + + describe 'has_products' do + context 'when the taxon has products' do + let!(:product) { create(:product, taxons: [taxon]) } + + it 'returns true' do + expect(subject[:data][:attributes][:has_products]).to be true + end + end + + context 'when the taxon does not have products' do + it 'returns false' do + expect(subject[:data][:attributes][:has_products]).to be false + end + end + end +end diff --git a/spec/serializers/spree/v2/storefront/vendor_image_serializer_spec.rb b/spec/serializers/spree/v2/storefront/vendor_image_serializer_spec.rb deleted file mode 100644 index 52257ae9..00000000 --- a/spec/serializers/spree/v2/storefront/vendor_image_serializer_spec.rb +++ /dev/null @@ -1,18 +0,0 @@ -require 'spec_helper' - -describe Spree::V2::Storefront::VendorImageSerializer do - let!(:vendor_image) { create(:vendor_image) } - let!(:vendor) { create(:active_vendor, name: 'vendor', image: vendor_image) } - - subject { described_class.new(vendor_image) } - - it { expect(subject.serializable_hash).to be_kind_of(Hash) } - - it 'returns right data attributes' do - expect(subject.serializable_hash[:data].keys).to contain_exactly(:id, :type, :attributes) - end - - it 'returns right vendor_image attributes' do - expect(subject.serializable_hash[:data][:attributes].keys).to contain_exactly(:styles) - end -end diff --git a/spec/serializers/spree/v2/storefront/vendor_order_totals_serializer_spec.rb b/spec/serializers/spree/v2/storefront/vendor_order_totals_serializer_spec.rb deleted file mode 100644 index 4860fdc1..00000000 --- a/spec/serializers/spree/v2/storefront/vendor_order_totals_serializer_spec.rb +++ /dev/null @@ -1,54 +0,0 @@ -require 'spec_helper' - -describe Spree::V2::Storefront::VendorOrderTotalsSerializer do - let(:vendor) { create(:vendor, name: 'Test', about_us: 'Hello World') } - let(:order) { create(:completed_order_with_totals, line_items_count: 6, line_items_price: 100, shipment_cost: 50) } - let!(:shipment) { create(:shipment, order: order, cost: 20, pre_tax_amount: 20, stock_location: vendor.stock_locations.first) } - let!(:promotion) { create(:promotion_with_item_adjustment, adjustment_rate: 10, code: '10off') } - - let(:vendor_order_totals) { Spree::VendorOrderTotals.new(order: order, vendor: vendor) } - - before do - order.products.update_all(vendor_id: vendor.id) - order.vendor_line_items(vendor).update_all(additional_tax_total: 10) - order.vendor_line_items(vendor).update_all(included_tax_total: 5) - order.vendor_shipments(vendor).update_all(adjustment_total: 5) - order.vendor_line_items(vendor).update_all(promo_total: 10) - end - - subject { described_class.new(vendor_order_totals) } - - let(:expected_hash) do - { - data: { - id: vendor.id.to_s, - type: :vendor_totals, - attributes: { - name: 'Test', - additional_tax_total: BigDecimal(60), - display_additional_tax_total: Spree::Money.new(60), - included_tax_total: BigDecimal(30), - display_included_tax_total: Spree::Money.new(30), - ship_total: BigDecimal(25), - display_ship_total: Spree::Money.new(25), - subtotal: BigDecimal(680), - display_subtotal: Spree::Money.new(680), - promo_total: BigDecimal(60), - display_promo_total: Spree::Money.new(60), - total: BigDecimal(625), - display_total: Spree::Money.new(625), - item_count: 6, - pre_tax_item_amount: BigDecimal(600), - display_pre_tax_item_amount: Spree::Money.new(600), - pre_tax_ship_amount: BigDecimal(20), - display_pre_tax_ship_amount: Spree::Money.new(20), - pre_tax_total: BigDecimal(620), - display_pre_tax_total: Spree::Money.new(620) - } - } - } - end - - it { expect(subject.serializable_hash).to be_kind_of(Hash) } - it { expect(subject.serializable_hash).to eq(expected_hash) } -end diff --git a/spec/serializers/spree/v2/storefront/vendor_serializer_spec.rb b/spec/serializers/spree/v2/storefront/vendor_serializer_spec.rb index 1f6bc4f7..8f935f23 100644 --- a/spec/serializers/spree/v2/storefront/vendor_serializer_spec.rb +++ b/spec/serializers/spree/v2/storefront/vendor_serializer_spec.rb @@ -1,34 +1,74 @@ require 'spec_helper' describe Spree::V2::Storefront::VendorSerializer do - let(:vendor) { create(:active_vendor, name: 'Test', about_us: 'Hello World', contact_us: 'Contact Info') } - - subject { described_class.new(vendor) } - - it { expect(subject.serializable_hash).to be_kind_of(Hash) } - - it do - expect(subject.serializable_hash).to eq( - { - data: { - id: vendor.id.to_s, - type: :vendor, - relationships: { - image: { - data: nil - }, - products: { - data: [] - } - }, - attributes: { - name: 'Test', - contact_us: 'Contact Info', - about_us: 'Hello World', - slug: 'test' - } + include_context 'API v2 serializers params' + + subject { described_class.new(vendor, params: serializer_params.merge({ include_products: include_products })).serializable_hash } + + let(:vendor) do + create(:vendor, about: "Click here!") + end + + let(:url_helpers) { Rails.application.routes.url_helpers } + + let(:include_products) { false } + + describe 'products' do + let(:include_products) { true } + + let!(:active_product) { create(:product, vendor: vendor) } + let!(:inactive_product) { create(:product, vendor: vendor, status: :draft) } + + it 'should return only products that are active' do + expect(subject[:data][:relationships][:products][:data].count).to eq(1) + expect(subject[:data][:relationships][:products][:data]).to match_array( + [ + { type: :product, id: active_product.id.to_s }, + ] + ) + end + end + + it 'returns the plain text of the about section' do + expect(subject[:data][:attributes][:about_us]).to eq("Click here!") + end + + it 'returns the contact email' do + expect(subject.dig(:data, :attributes, :email)).to eq(vendor.contact_person_email) + end + + it 'returns the slug' do + expect(subject.dig(:data, :attributes, :slug)).to eq(vendor.slug) + end + + context 'with a logo' do + let(:vendor) { create(:vendor, :with_logo) } + + it 'returns the logo url' do + expect(subject.dig(:data, :attributes, :logo_url)).to eq(url_helpers.cdn_image_url(vendor.logo.attachment)) + end + end + + context 'with a cover photo' do + let(:vendor) { create(:vendor, :with_cover_photo) } + + it 'returns the cover photo url' do + expect(subject.dig(:data, :attributes, :cover_photo_url)).to eq(url_helpers.cdn_image_url(vendor.cover_photo.attachment)) + end + end + + context 'with metafields' do + before do + vendor.set_metafield('test.test', 'test') + end + + it 'returns the metafields' do + expect(subject.dig(:data, :relationships, :metafields, :data)).to contain_exactly( + { + type: :metafield, + id: vendor.metafields.first.id.to_s } - } - ) + ) + end end end diff --git a/spec/services/spree/imports/row_processors/product_variant_spec.rb b/spec/services/spree/imports/row_processors/product_variant_spec.rb new file mode 100644 index 00000000..a0117ead --- /dev/null +++ b/spec/services/spree/imports/row_processors/product_variant_spec.rb @@ -0,0 +1,56 @@ +require 'spec_helper' + +RSpec.describe Spree::Imports::RowProcessors::ProductVariant, type: :service do + subject { described_class.new(row) } + + let(:store) { Spree::Store.default } + let(:vendor) { create(:approved_vendor) } + let(:vendor_user) { create(:vendor_user, vendor: vendor) } + let(:import) { create(:product_import, owner: vendor, user: vendor_user) } + let(:row) { create(:import_row, import: import, data: row_data.to_json) } + let(:csv_row_headers) { Spree::ImportSchemas::Products.new.headers } + let(:variant) { subject.process! } + + before do + import.create_mappings + end + + # Matches how our production import will pass attributes + def csv_row_hash(attrs = {}) + csv_row_headers.index_with { |header| attrs[header] } + end + + let(:row_data) do + csv_row_hash( + 'slug' => 'denim-shirt', + 'name' => 'Denim Shirt', + 'status' => 'draft', + 'description' => 'Adipisci sapiente velit nihil ullam. Placeat cumque ipsa cupiditate velit magni sapiente mollitia dolorum. Veritatis esse illo eos perferendis. Perspiciatis vel iusto odio eveniet quam officia quidem. Fugiat a ipsum tempore optio accusantium autem in fugit.', + 'price' => '62.99', + 'currency' => 'USD', + 'weight' => '0.0', + 'inventory_count' => '100', + 'inventory_backorderable' => 'true', + 'tags' => 'ECO, Gold' + ) + end + + context 'when importing a master variant product row' do + it 'creates a product and associates it to the vendor' do + expect { subject.process! }.to change(Spree::Product, :count).by(1) + product = variant.product + expect(product).to be_persisted + expect(product.slug).to eq 'denim-shirt' + expect(product.name).to eq 'Denim Shirt' + expect(variant.vendor).to eq vendor + end + end + + context 'fails when trying to update an existing non-vendor product' do + let!(:product) { create(:product, slug: 'denim-shirt', name: 'Denim Shirt', stores: [store]) } + + it 'fails to update the product' do + expect { subject.process! }.to raise_error(ActiveRecord::RecordInvalid) + end + end +end diff --git a/spec/services/spree/orders/create_vendor_order_spec.rb b/spec/services/spree/orders/create_vendor_order_spec.rb new file mode 100644 index 00000000..7518ea99 --- /dev/null +++ b/spec/services/spree/orders/create_vendor_order_spec.rb @@ -0,0 +1,361 @@ +require 'spec_helper' + +RSpec.describe Spree::Orders::CreateVendorOrder do + let(:store) { Spree::Store.default } + let!(:default_stock_location) { create(:stock_location, name: 'default', default: true) } + let(:user) { create(:user) } + let(:parent_order) do + create(:order, store: store, currency: 'USD', user: user, + special_instructions: 'Hi there', last_ip_address: '127.0.0.1', + bill_address: create(:address, user: user), ship_address: create(:address, user: user)) + end + let(:vendor_order) { subject.value } + + let(:vendor) { create(:approved_vendor, platform_fee: 10.to_d) } + let(:vendor_2) { create(:approved_vendor) } + + let(:product) { create(:vendor_product_in_stock, vendor: vendor, price: 90.0) } + let(:product_2) { create(:vendor_product_in_stock, vendor: vendor, price: 49.0) } + let(:product_3) { create(:vendor_product_in_stock, vendor: vendor_2, price: 100.0) } + let(:variant) { product.default_variant } + let(:variant_2) { product_2.default_variant } + let(:variant_3) { product_3.default_variant } + + let!(:line_item) { create(:line_item, order: parent_order, product: product, price: product.price) } + let!(:line_item_2) { create(:line_item, order: parent_order, product: product_2, price: product_2.price) } + let!(:line_item_3) { create(:line_item, order: parent_order, product: product_3, price: product_3.price) } + + let!(:payment) { create(:payment, order: parent_order, amount: parent_order.total) } + + subject { described_class.call(order: parent_order, vendor: vendor, index: 2) } + + context 'dynamic shipping' do + let!(:shipment) { create(:shipment, order: parent_order, stock_location: vendor.stock_locations.first, cost: 5.0) } + let!(:shipment_2) { create(:shipment, order: parent_order, stock_location: vendor_2.stock_locations.first, cost: 5.0) } + + let!(:shipment_adjustments) { create_list(:tax_adjustment, 2, adjustable: shipment, order: parent_order, amount: 10.0) } + let!(:shipment_2_adjustments) { create(:tax_adjustment, adjustable: shipment_2, order: parent_order) } + + before do + parent_order.update_with_updater! + parent_order.update( + completed_at: '2020-11-01 10:00:00', + state: 'complete', + payment_state: 'balance_due', + shipment_state: 'pending', + confirmation_delivered: true + ) + end + + describe 'new vendor order' do + it { expect { subject }.to change { Spree::Order.has_vendor.count }.by(1) } + + it 'creates a new vendor order' do + expect(vendor_order).to have_attributes( + number: "#{parent_order.number}-2", + email: user.email, + user_id: user.id, + store_id: store.id, + special_instructions: 'Hi there', + last_ip_address: '127.0.0.1', + state: 'complete', + payment_state: 'balance_due', + shipment_state: 'pending', + completed_at: parent_order.completed_at, + confirmation_delivered: true, + item_count: 2, + item_total: 139.to_d, + platform_fee_total: -13.9.to_d, # vendor fee is 10% of item total + shipment_total: 5.to_d, + adjustment_total: 0.to_d, + additional_tax_total: 1.to_d, + total: 144.to_d, # we don't include additional tax in vendor order total + vendor_id: vendor.id, + parent_id: parent_order.id, + currency: parent_order.currency + ) + end + + it 'calculates platform fee total correctly' do + expect(vendor_order.platform_fee_total).to eq(-0.1 * vendor_order.item_total) + end + + it 'calculates total correctly' do + expect(vendor_order.total).to eq( + vendor_order.item_total + vendor_order.shipment_total + vendor_order.adjustment_total + ) + end + + it 'updates vendor cache' do + subject + expect(vendor.reload.sales_total).to eq vendor.orders.complete.sum(:total) + expect(vendor.commission_total).to eq vendor.orders.complete.sum(:platform_fee_total).abs + end + + it 'cannot create the same order twice' do + expect { subject }.to change { Spree::Order.has_vendor.count }.by(1) + expect { subject }.not_to change { Spree::Order.has_vendor.count } + end + + context 'if default store currency is different than parent order currency' do + it "sets vendor's order currency to parent_order's currency" do + allow_any_instance_of(Spree::Store).to receive(:default_currency).and_return('EUR') + + subject + expect(vendor_order.currency).to eq 'USD' + end + end + end + + describe 'moves vendor line items to vendor_order' do + it { expect { subject }.not_to change { Spree::LineItem.count } } + it { expect { subject }.not_to change { Spree::InventoryUnit.count } } + it { expect { subject }.not_to change { Spree::Adjustment.where.not(source: nil).count } } + + it { expect { subject }.to change { parent_order.line_items.count }.from(3).to(1) } + + it 'changes order for line items' do + expect(line_item.order).to eq(parent_order) + expect(line_item_2.order).to eq(parent_order) + expect(line_item_3.order).to eq(parent_order) + subject + expect(line_item.reload.order).to eq(vendor_order) + expect(line_item_2.reload.order).to eq(vendor_order) + expect(line_item_3.reload.order).to eq(parent_order) + end + end + + describe 'assigns addresses to the new order' do + context 'guest order' do + before do + parent_order.update!(user: nil, email: 'new@email.com') + parent_order.bill_address.update!(user: nil) + parent_order.ship_address.update!(user: nil) + end + + it 'assigns the same addresses' do + expect(vendor_order.bill_address).to eq(parent_order.bill_address.reload) + expect(vendor_order.ship_address).to eq(parent_order.ship_address.reload) + end + it { expect { subject }.not_to change(Spree::Address, :count) } + end + + context 'signed in user' do + it 'assigns the same addresses' do + expect(vendor_order.bill_address).to eq(parent_order.bill_address.reload) + expect(vendor_order.ship_address).to eq(parent_order.ship_address.reload) + end + it { expect { subject }.not_to change(Spree::Address, :count) } + end + end + + describe 'create_payments' do + it 'creates vendor payment' do + subject + + vendor_payment = vendor_order.payments.last + + expect(vendor_order.payments.count).to eq(1) + expect(vendor_payment.amount).to eq vendor_order.total + expect(vendor_payment.source).to eq payment.source + expect(vendor_payment.response_code).to eq payment.response_code + expect(vendor_payment.payment_method).to eq payment.payment_method + expect(vendor_payment.state).to eq 'completed' + end + end + + describe 'create_store_credit_payments' do + let(:store_credit) { create(:store_credit, amount: store_credit_amount, user: parent_order.user) } + let(:store_credit_payment) { create(:store_credit_payment, source: store_credit, order: parent_order, amount: store_credit_amount, state: 'completed') } + + context 'when order is partially covered by store credit' do + let(:store_credit_amount) { 20 } + + before do + store_credit_payment + end + + it 'creates a store credit payment with the proportional amount for a vendor order' do + subject + + vendor_payment = vendor_order.payments.store_credits.last + + expect(vendor_order.payments.store_credits.count).to eq(1) + expect(vendor_payment.vendor).to eq(vendor) + expect(vendor_payment.number).to eq("#{store_credit_payment.number}-2") + expect(vendor_payment.amount).to eq(11.63) + expect(vendor_payment.source).to eq(store_credit_payment.source) + expect(vendor_payment.response_code).to eq(store_credit_payment.response_code) + expect(vendor_payment.payment_method).to eq(store_credit_payment.payment_method) + expect(vendor_payment.state).to eq('completed') + end + + it 'creates payment for the remaining amount' do + subject + + cc_payment = vendor_order.payments.not_store_credits.last + store_credit_payment = vendor_order.payments.store_credits.last + + expect(vendor_order.payments.not_store_credits.count).to eq(1) + expect(cc_payment.amount).to eq(vendor_order.item_total + vendor_order.ship_total - store_credit_payment.amount) + expect(cc_payment.state).to eq('completed') + end + end + + context 'when order is fully covered by store credit' do + let(:store_credit_amount) { parent_order.total } + + before do + payment.destroy + parent_order.reload + + store_credit_payment + end + + it 'creates a store credit payment using proportional total split' do + subject + + vendor_payment = vendor_order.payments.store_credits.last + + expect(vendor_order.payments.store_credits.count).to eq(1) + expect(vendor_payment.number).to eq("#{store_credit_payment.number}-2") + expect(vendor_payment.amount).to eq(144) + expect(vendor_payment.source).to eq(store_credit_payment.source) + expect(vendor_payment.payment_method).to eq(store_credit_payment.payment_method) + expect(vendor_payment.state).to eq('completed') + end + + it 'does not create other payments' do + subject + expect(vendor_order.payments.not_store_credits.count).to eq(0) + end + end + end + + describe 'moves vendor shipments to vendor_order' do + it 'changes order for shipments and shipment adjustments' do + expect(parent_order.shipments.count).to eq(2) + expect(parent_order.shipment_adjustments.count).to eq(3) + + subject + + expect(vendor_order.reload.shipments.count).to eq(1) + expect(vendor_order.shipment_adjustments.count).to eq(2) + + expect(parent_order.shipments.count).to eq(1) + expect(parent_order.shipment_adjustments.count).to eq(1) + end + + it 'assigns addresse from order to the new shipment' do + subject + + expect(vendor_order.reload.shipments.pluck(:address_id).uniq).to eq([vendor_order.ship_address_id]) + end + end + + describe 'create_marketplace_commissions' do + it 'triggers SpreeMultiVendor::Fees::CreateAllItemsFees' do + expect(SpreeMultiVendor::Fees::CreateAllItemsFees).to receive_message_chain(:new, :call).and_return([]) + + subject + end + + context 'when order is fully covered by store credit' do + before do + payment.destroy + parent_order.reload + + store_credit = create(:store_credit, amount: parent_order.total, user: parent_order.user) + create(:store_credit_payment, source: store_credit, order: parent_order, amount: parent_order.total, state: 'completed') + end + + it 'does not create marketplace commissions' do + expect(SpreeMultiVendor::Fees::CreateAllItemsFees).not_to receive(:new) + subject + end + + it 'vendor order has zero platform fee total' do + subject + expect(vendor_order.platform_fee_total).to eq(0) + end + end + end + end + + xcontext 'with commission discount' do + let(:promotion) { create(:promotion, kind: :automatic) } + let(:vendor) { create(:approved_vendor, platform_fee: 50.0) } + let(:vendor_2) { create(:approved_vendor, platform_fee: 20.0) } + let(:vendor_2_order) { described_class.call(order: parent_order, vendor: vendor_2, index: 1).value } + + before do + Spree::Promotion::Actions::CreateAdjustment.create!(calculator: calculator, promotion: promotion) + promotion.activate(order: parent_order.reload) + end + + context 'with amount calculator' do + let(:calculator) do + calculator = Spree::Calculator::FlatRate.new + calculator.preferred_amount = 50 + calculator.preferred_currency = 'USD' + calculator + end + + it 'should be counted as one usage' do + expect(promotion.reload.credits_count).to eq(1) + end + + it 'calculates platform fee for vendor orders correctly' do + expect(parent_order.promo_total).to eq(-50) + + expect(vendor_order.reload.platform_fee_total).to eq(-19.5) + expect(vendor_2_order.reload.platform_fee_total).to eq(-20) + + expect(vendor_order.total.to_f).to eq(69.5) # 135 * 0.5 + expect(vendor_2_order.total).to eq(80) # 100 * 0.8 + + expect(vendor_order.item_total).to eq(139) + expect(vendor_2_order.item_total).to eq(100) + + expect(vendor_order.line_items.first.platform_fee_total).to eq(0.0) + expect(vendor_order.line_items.first.private_metadata[:platform_fee_discount].to_f).to eq(45) + expect(vendor_order.line_items.second.platform_fee_total).to eq(19.5) + expect(vendor_order.line_items.second.private_metadata[:platform_fee_discount].to_f).to eq(5.0) + expect(vendor_2_order.line_items.first.platform_fee_total).to eq(20) + expect(vendor_2_order.line_items.first.private_metadata[:platform_fee_discount].to_f).to eq(0.0) + end + end + + xcontext 'with percent calculator' do + let(:calculator) do + calculator = Spree::Calculator::FlatPercentItemTotal.new + calculator.preferred_flat_percent = 10 + calculator + end + + it 'should be counted as one usage' do + expect(promotion.reload.credits_count).to eq(1) + end + + it 'calculates platform fee for vendor orders correctly' do + expect(parent_order.promo_total).to eq(-23.9) # 10% of 239 + + expect(vendor_order.reload.platform_fee_total).to eq(-45.6) + expect(vendor_2_order.reload.platform_fee_total).to eq(-20) + + expect(vendor_order.total.to_f).to eq(69.5) + expect(vendor_2_order.total).to eq(80) + + expect(vendor_order.item_total).to eq(139) + expect(vendor_2_order.item_total).to eq(100) + + expect(vendor_order.line_items.first.platform_fee_total).to eq(21.1) + expect(vendor_order.line_items.first.private_metadata[:platform_fee_discount].to_f).to eq(23.9) + expect(vendor_order.line_items.second.platform_fee_total).to eq(24.5) + expect(vendor_order.line_items.second.private_metadata[:platform_fee_discount].to_f).to eq(0.0) + expect(vendor_2_order.line_items.first.platform_fee_total).to eq(20) + expect(vendor_2_order.line_items.first.private_metadata[:platform_fee_discount].to_f).to eq(0.0) + end + end + end +end diff --git a/spec/services/spree/orders/split_by_vendor_spec.rb b/spec/services/spree/orders/split_by_vendor_spec.rb new file mode 100644 index 00000000..2cf3c050 --- /dev/null +++ b/spec/services/spree/orders/split_by_vendor_spec.rb @@ -0,0 +1,205 @@ +require 'spec_helper' + +RSpec.describe Spree::Orders::SplitByVendor do + let(:store) { Spree::Store.default } + + let(:user) { create(:user) } + let(:parent_order) do + create(:completed_order_with_totals, + special_instructions: 'Hi there', + last_ip_address: '127.0.0.1', + without_line_items: true, + user: user, + bill_address: create(:address, user: user), + ship_address: create(:address, user: user)) + end + + let!(:vendor) { create(:approved_vendor) } + let!(:vendor_2) { create(:approved_vendor) } + let!(:vendor_user) { create(:vendor_user, vendor: vendor, email: vendor.contact_person_email) } + let!(:vendor_user_2) { create(:vendor_user, vendor: vendor_2, email: vendor_2.contact_person_email) } + + let(:tax_category) { create(:tax_category) } + let(:tax_category_2) { create(:tax_category) } + let(:product) { create(:product_in_stock, tax_category: tax_category, vendor: vendor, price: 90.0) } + let(:product_2) { create(:product_in_stock, tax_category: tax_category, vendor: vendor, price: 49.0) } + let(:product_3) { create(:product_in_stock, tax_category: tax_category_2, vendor: vendor_2, price: 100.0) } + + let(:variant) { product.default_variant } + let(:variant_2) { product_2.default_variant } + let(:variant_3) { product_3.default_variant } + + let!(:line_item) { create(:line_item, order: parent_order, product: product, price: product.price) } + let!(:line_item_2) { create(:line_item, order: parent_order, product: product_2, price: product_2.price) } + let!(:line_item_3) { create(:line_item, order: parent_order, product: product_3, price: product_3.price) } + + let(:order_item_total) { 239.to_d } + let(:order_promo_total) { -10.to_d } + let(:order_shipment_total) { 20.to_d } + let(:order_additional_tax_total) { 13.9.to_d } + let(:order_total) { order_item_total + order_promo_total + order_shipment_total + order_additional_tax_total } + + let(:vendor_item_total) { 139.to_d } + let(:vendor_shipment_total) { 10.to_d } + let(:vendor_additional_tax_total) { 13.9.to_d } + let(:vendor_platform_fee_total) { -0.3 * vendor_item_total } + let(:vendor_order_total) { vendor_item_total + vendor_shipment_total } + + let(:vendor_2_item_total) { 100.to_d } + let(:vendor_2_shipment_total) { 10.to_d } + let(:vendor_2_additional_tax_total) { 0.0.to_d } + let(:vendor_2_platform_fee_total) { -0.3 * vendor_2_item_total } + let(:vendor_2_order_total) { vendor_2_item_total + vendor_2_shipment_total } + + before do + parent_order.shipments.destroy_all + end + + context 'when not parent order' do + let(:vendor_order) { build(:order, parent: parent_order, vendor: vendor) } + + it 'returns failed' do + expect(subject.call(order: vendor_order).success?).to be(false) + end + + it 'does not send order confirmation email to vendor' do + expect { subject.call(order: vendor_order) }.not_to change(Spree::OrderMailer.deliveries, :count) + end + end + + context 'when already all splitted' do + let!(:vendor_order) { create(:order, parent: parent_order, vendor: vendor) } + let!(:vendor_order_2) { create(:order, parent: parent_order, vendor: vendor_2) } + + it 'returns failed' do + expect(subject.call(order: parent_order).success?).to be(false) + end + + it 'does not send order confirmation email to vendor' do + expect { subject.call(order: vendor_order) }.not_to change(Spree::OrderMailer.deliveries, :count) + end + end + + it 'calls the Spree::Orders::CreateVendorOrder service per vendor' do + create_vendor_order_service = double(:create_vendor_order_service, call: nil) + allow(Spree::Orders::CreateVendorOrder).to receive(:new).and_return(create_vendor_order_service) + + subject.call(order: parent_order) + + expect(create_vendor_order_service).to have_received(:call).twice + end + + it 'splits the parent order into vendor_orders per supplier' do + create(:payment, order: parent_order) + + subject.call(order: parent_order) + + expect(parent_order.vendor_orders.count).to eq(2) + end + + it 'sends vendor order confirmation email to each vendor' do + create(:payment, order: parent_order) + clear_enqueued_jobs + subject.call(order: parent_order) + perform_enqueued_jobs(except: Spree::Addresses::GeocodeAddressJob) + + mail_1 = Spree::OrderMailer.deliveries.find { |delivery| delivery.to.include?(vendor.contact_person_email) } + vendor_order_1 = vendor.orders.last + + expect(mail_1).to be_present + expect(mail_1.subject).to eq('New Order Received') + expect(mail_1.body).to include(vendor_order_1.number) + + mail_2 = Spree::OrderMailer.deliveries.find { |delivery| delivery.to.include?(vendor_2.contact_person_email) } + vendor_order_2 = vendor_2.orders.last + + expect(mail_2).to be_present + expect(mail_2.subject).to eq('New Order Received') + expect(mail_2.body).to include(vendor_order_2.number) + end + + it 'stores correct totals' do + setup_order_with_everything + + expect(parent_order.vendor_orders.count).to eq(0) + + check_totals + + subject.call(order: parent_order) + parent_order.reload + + expect(parent_order.vendor_orders.count).to eq(2) + expect(parent_order.vendor_orders.pluck(:platform_fee_total)).to contain_exactly(vendor_platform_fee_total, vendor_2_platform_fee_total) + expect(parent_order.vendor_orders.pluck(:additional_tax_total)).to contain_exactly(vendor_additional_tax_total, vendor_2_additional_tax_total) + + # vendor order includes no tax in order total + expect(parent_order.vendor_orders.pluck(:total)).to contain_exactly(vendor_order_total, vendor_2_order_total) + + check_totals + end + + it 'keeps taxes in the vendor order' do + setup_order_with_everything + + expect(parent_order.vendor_orders.count).to eq(0) + + check_totals + + subject.call(order: parent_order) + parent_order.reload + + expect(parent_order.vendor_orders.count).to eq(2) + expect(parent_order.vendor_order_line_item_adjustments.count).to eq(2) + + expect(parent_order.vendor_order_line_item_adjustments.pluck(:amount)).to match_array [9.0, 4.9] + + check_totals + end + + private + + def check_totals + expect(parent_order.item_total).to eq(order_item_total) + expect(parent_order.total).to eq(order_total) + expect(parent_order.adjustment_total).to eq(order_promo_total + order_additional_tax_total) + expect(parent_order.payment_total).to eq(order_total) + expect(parent_order.shipment_total).to eq(order_shipment_total) + expect(parent_order.additional_tax_total).to eq(order_additional_tax_total) + expect(parent_order.promo_total).to eq(order_promo_total) + expect(parent_order.included_tax_total).to eq(0.0) + expect(parent_order.taxable_adjustment_total).to eq(order_promo_total) + expect(parent_order.non_taxable_adjustment_total).to eq(0.0) + expect(parent_order.platform_fee_total).to eq(0.0) + end + + def setup_order_with_everything + create_promotion + create_shipments + create_tax_rates + update_parent_order + + create(:payment, order: parent_order, amount: parent_order.total, state: :completed) + end + + def update_parent_order + parent_order.reload + parent_order.update_line_item_prices! + parent_order.create_tax_charge! + parent_order.update_with_updater! + end + + def create_tax_rates + create(:tax_rate, amount: 0.1, tax_category: tax_category, zone: parent_order.tax_zone) + end + + def create_shipments + create(:shipment, order: parent_order, cost: 10, stock_location: variant.stock_items.first.stock_location, vendor: vendor, state: 'ready') + create(:shipment, order: parent_order, cost: 10, stock_location: variant_3.stock_items.first.stock_location, vendor: vendor_2, state: 'ready') + end + + def create_promotion + parent_order.update!(state: 'cart') + create(:promotion, :with_order_adjustment, weighted_order_adjustment_amount: 10, stores: [store]).activate(order: parent_order) + parent_order.update!(state: 'complete') + end +end diff --git a/spec/services/spree/refunds/calculate_additional_tax_spec.rb b/spec/services/spree/refunds/calculate_additional_tax_spec.rb new file mode 100644 index 00000000..60a6a217 --- /dev/null +++ b/spec/services/spree/refunds/calculate_additional_tax_spec.rb @@ -0,0 +1,57 @@ +RSpec.describe Spree::Refunds::CalculateAdditionalTax do + subject(:calculate_additional_tax) { described_class.call(refund: refund).value } + + let(:refund) { create(:refund, amount: 100, payment: order.payments.first, reimbursement: reimbursement) } + + let!(:order) { create(:vendor_shipped_order, without_line_items: true) } + let!(:line_items) do + [ + create(:vendor_line_item, order: order, price: 10, quantity: 2), + create(:vendor_line_item, order: order, price: 20, quantity: 3) + ] + end + + let(:shipment) { order.shipments[0] } + + let(:reimbursement) { nil } + let(:currency) { refund.currency } + + let!(:tax_adjustment_1) { create(:tax_adjustment, adjustable: line_items[0], order: order, amount: 2) } + let!(:tax_adjustment_2) { create(:tax_adjustment, adjustable: line_items[1], order: order, amount: 6) } + + context 'for a parent order' do + let!(:order) { create(:shipped_order, without_line_items: true) } + let!(:line_items) do + [ + create(:line_item, order: order, price: 10, quantity: 2), + create(:line_item, order: order, price: 20, quantity: 3) + ] + end + + it { is_expected.to eq(Spree::Money.new(0, currency: order.currency)) } + end + + context 'with reimbursement' do + let(:reimbursement) { create(:reimbursement, return_items_count: 2) } + + before do + reimbursement.return_items[0].inventory_unit.update!(quantity: 1, line_item_id: line_items[0].id) + reimbursement.return_items[1].inventory_unit.update!(quantity: 2, line_item_id: line_items[1].id) + end + + it 'calculates the sales tax for returned items' do + expect(calculate_additional_tax).to eq(Spree::Money.new(5.0, currency: currency)) + end + end + + context 'without reimbursement' do + before do + order.line_items.reload + order.shipments[0].update_column(:additional_tax_total, 3) + end + + it 'calculates the sales tax for all items' do + expect(calculate_additional_tax).to eq(Spree::Money.new(11.0, currency: currency)) + end + end +end diff --git a/spec/services/spree_multi_vendor/fees/calculate_item_fee_spec.rb b/spec/services/spree_multi_vendor/fees/calculate_item_fee_spec.rb new file mode 100644 index 00000000..a3b0186e --- /dev/null +++ b/spec/services/spree_multi_vendor/fees/calculate_item_fee_spec.rb @@ -0,0 +1,136 @@ +require 'spec_helper' + +RSpec.describe SpreeMultiVendor::Fees::CalculateItemFee do + let(:line_item) do + create(:line_item, + price: 99.99, + quantity: 2, + promo_total: 0.00, + product: create(:product_in_stock, vendor: vendor), + vendor: vendor) + end + let(:rate) { nil } + let(:vendor) { create(:vendor, platform_fee: vendor_platform_fee) } + let(:vendor_platform_fee) { 10 } + let(:product_platform_fee) { 5 } + + before { allow(vendor).to receive(:payment_gateway_connected?).and_return true } + + describe '#call' do + subject { described_class.call(item: line_item, rate: rate) } + let(:result) { subject.value } + + context 'if rate is not passed' do + context 'if product platform fee is present' do + before { line_item.product.update!(platform_fee: product_platform_fee) } + + it 'sets line item platform fee per unit correctly' do + expect(result[:fee_per_unit]).to eq 5 + end + + it 'sets line item platform fee total correctly' do + expect(result[:fee_total]).to eq 10 + end + + it 'sets line item platform fee rate correctly' do + expect(result[:rate]).to eq product_platform_fee + end + end + + context 'if product platform fee is not present, but vendor platform fee is present' do + it 'sets line item platform fee per unit correctly' do + expect(result[:fee_per_unit]).to eq 10 + end + + it 'sets line item platform fee total correctly' do + expect(result[:fee_total]).to eq 20 + end + + it 'sets line item platform fee rate correctly' do + expect(result[:rate]).to eq vendor_platform_fee + end + end + + context 'if rate is zero' do + before { line_item.product.update!(platform_fee: 0.0) } + + it 'returns failure' do + expect(subject.failure?).to be true + end + end + end + + context 'if custom rate is passed' do + before { subject } + + let(:rate) { 50.0 } + + it 'sets line item platform fee per unit correctly' do + expect(result[:fee_per_unit]).to eq 50 + end + + it 'sets line item platform fee total correctly' do + expect(result[:fee_total]).to eq 100 + end + + it 'sets line item platform fee rate correctly' do + expect(result[:rate]).to eq rate + end + end + + context 'when line item is discounted' do + before do + line_item.update(promo_total: -10.0) + line_item.reload + end + + it 'sets line item platform fee per unit correctly' do + expect(result[:fee_per_unit]).to eq 9.5 + end + + it 'sets line item platform fee total correctly' do + expect(result[:fee_total]).to eq 19 + end + + it 'sets line item platform fee rate correctly' do + expect(result[:rate]).to eq vendor_platform_fee + end + end + + context 'when store credit is applied to the order' do + let(:store) { Spree::Store.default } + let(:order) { create(:order, store: store) } + + let(:other_vendor) { create(:vendor, platform_fee: vendor_platform_fee) } + + let!(:line_item) { create(:line_item, order: order, product: product, price: 50, quantity: 2) } + let!(:line_item2) { create(:line_item, order: order, product: product2, price: 10, quantity: 2) } + let!(:line_item3) { create(:line_item, order: order, product: product3, price: 30, quantity: 1) } + + let(:product) { create(:product_in_stock, vendor: vendor, price: 50) } + let(:product2) { create(:product_in_stock, vendor: vendor, price: 10) } + let(:product3) { create(:product_in_stock, vendor: other_vendor, price: 30) } + + before do + order.update_with_updater! + create(:store_credit_payment, order: order, amount: 30) + end + + it 'calculates store credit per unit correctly' do + # store_credit_per_unit = (50 / 150) * 30 = 10 + expect(result[:store_credit_per_unit]).to eq(10) + end + + it 'calculates fee per unit correctly after deducting store credit' do + # feeable_amount = 50 - 10 (store_credit) = 40 + # fee_per_unit = 40 * 10% = 4 + expect(result[:fee_per_unit]).to eq(4) + end + + it 'calculates fee total correctly' do + # fee_total = 4 * 2 = 8 + expect(result[:fee_total]).to eq(8) + end + end + end +end diff --git a/spec/services/spree_multi_vendor/fees/create_all_items_fees_spec.rb b/spec/services/spree_multi_vendor/fees/create_all_items_fees_spec.rb new file mode 100644 index 00000000..b7964cce --- /dev/null +++ b/spec/services/spree_multi_vendor/fees/create_all_items_fees_spec.rb @@ -0,0 +1,67 @@ +require 'spec_helper' + +RSpec.describe SpreeMultiVendor::Fees::CreateAllItemsFees do + subject { described_class.new(order: order.reload).call } + + let!(:order) { create(:vendor_order_with_line_items, line_items_count: 3, vendor: vendor) } + let(:vendor) { create(:vendor, platform_fee: vendor_platform_fee) } + let(:vendor_platform_fee) { 10 } + let(:product_platform_fee) { 5 } + let(:fees_amount) { 15.0 } + + let(:line_item_1) { order.line_items.first } + let(:line_item_2) { order.line_items.second } + let(:line_item_3) { order.line_items.third } + + let(:fee_1) { subject.find { |fee| fee.feeable == line_item_1 } } + let(:fee_2) { subject.find { |fee| fee.feeable == line_item_2 } } + let(:fee_3) { subject.find { |fee| fee.feeable == line_item_3 } } + + describe '#call' do + before do + allow(vendor).to receive(:payment_gateway_connected?).and_return true + + order.line_items.update_all(vendor_id: vendor.id, price: 99.99) + + line_item_1.product.update!(vendor: vendor, platform_fee: product_platform_fee) + line_item_2.product.update!(vendor: vendor) + line_item_3.product.update!(vendor: vendor) + end + + it 'creates application fee adjustments' do + subject + + expect(fee_1.amount).to eq(5) + expect(fee_1.label).to eq Spree::MarketplaceCommission::ITEM_LABEL + expect(fee_1.rate).to eq product_platform_fee + expect(fee_1.feeable.platform_fee_per_unit).to eq(5) + expect(fee_1.feeable.platform_fee_total).to eq(5) + + expect(fee_2.amount).to eq(10) + expect(fee_2.label).to eq Spree::MarketplaceCommission::ITEM_LABEL + expect(fee_2.rate).to eq vendor_platform_fee + expect(fee_2.feeable.platform_fee_per_unit).to eq(10) + expect(fee_2.feeable.platform_fee_total).to eq(10) + + expect(fee_3.amount).to eq(10) + expect(fee_3.label).to eq Spree::MarketplaceCommission::ITEM_LABEL + expect(fee_3.rate).to eq vendor_platform_fee + expect(fee_3.feeable.platform_fee_per_unit).to eq(10) + expect(fee_3.feeable.platform_fee_total).to eq(10) + end + + context 'for non vendorized items' do + before do + order.line_items.second.update_column(:vendor_id, nil) + end + + it 'does not call CreateItemFee' do + expect { subject }.to change(Spree::MarketplaceCommission, :count).by(2) + + expect(fee_1).to be_present + expect(fee_2).to be_blank + expect(fee_3).to be_present + end + end + end +end diff --git a/spec/services/spree_multi_vendor/fees/create_item_fee_spec.rb b/spec/services/spree_multi_vendor/fees/create_item_fee_spec.rb new file mode 100644 index 00000000..4e07f142 --- /dev/null +++ b/spec/services/spree_multi_vendor/fees/create_item_fee_spec.rb @@ -0,0 +1,137 @@ +require 'spec_helper' + +RSpec.describe SpreeMultiVendor::Fees::CreateItemFee do + subject { described_class.new(item: line_item, rate: rate).call } + + let(:line_item) { create(:line_item, price: 99.99, quantity: 2) } + let(:rate) { nil } + let(:vendor) { create(:vendor, platform_fee: vendor_platform_fee) } + let(:vendor_platform_fee) { 10 } + let(:product_platform_fee) { 5 } + + before do + line_item.product.update!(vendor: vendor) + line_item.update!(vendor: vendor) + + allow(vendor).to receive(:payment_gateway_connected?).and_return true + end + + describe '#call' do + context 'if rate is not passed' do + context 'if product platform fee is present' do + before do + line_item.product.update!(platform_fee: product_platform_fee) + end + + it 'calculates rate based on product platform fee and creates a fee' do + expect { subject }.to change { line_item.marketplace_commissions.count }.by(1) + expect(subject.amount).to eq(10) + expect(subject.label).to eq Spree::MarketplaceCommission::ITEM_LABEL + expect(subject.feeable).to eq(line_item) + end + + it 'sets line item platform fee per unit correctly' do + subject + + expect(line_item.reload.platform_fee_per_unit).to eq(5) + end + + it 'sets line item platform fee total correctly' do + subject + + expect(line_item.reload.platform_fee_total).to eq(10) + end + + it 'sets line item platform fee rate correctly' do + subject + + expect(line_item.reload.platform_fee_rate).to eq product_platform_fee + end + end + + context 'if product platform fee is not present' do + it 'calculates rate based on vendor platform fee and creates a fee' do + expect { subject }.to change { line_item.marketplace_commissions.count }.by(1) + expect(subject.amount).to eq(20) + expect(subject.label).to eq Spree::MarketplaceCommission::ITEM_LABEL + expect(subject.feeable).to eq(line_item) + end + + it 'sets line item platform fee per unit correctly' do + subject + + expect(line_item.reload.platform_fee_per_unit).to eq(10) + end + + it 'sets line item platform fee total correctly' do + subject + + expect(line_item.reload.platform_fee_total).to eq(20) + end + + it 'sets line item platform fee rate correctly' do + subject + + expect(line_item.reload.platform_fee_rate).to eq vendor_platform_fee + end + end + + context 'if rate is zero' do + before do + line_item.product.update!(platform_fee: 0.0) + end + + it 'does not create a platform fee' do + expect { subject }.not_to change { line_item.marketplace_commissions.count } + end + + it 'sets line item platform fee per unit correctly' do + subject + + expect(line_item.reload.platform_fee_per_unit).to eq 0.0 + end + + it 'sets line item platform fee total correctly' do + subject + + expect(line_item.reload.platform_fee_total).to eq 0.0 + end + + it 'sets line item platform fee rate correctly' do + subject + + expect(line_item.reload.platform_fee_rate).to eq nil + end + end + end + + context 'if custom rate is passed' do + let(:rate) { 50.0 } + + it 'calculates rate based on custom_rate and creates a fee' do + expect { subject }.to change { line_item.marketplace_commissions.count }.by(1) + expect(subject.amount).to eq(100) + expect(subject.label).to eq Spree::MarketplaceCommission::ITEM_LABEL + expect(subject.feeable).to eq(line_item) + end + + it 'sets line item platform fee per unit correctly' do + subject + + expect(line_item.reload.platform_fee_per_unit).to eq(50) + end + + it 'sets line item platform fee total correctly' do + subject + + expect(line_item.platform_fee_total).to eq(100) + end + + it 'sets line item platform fee rate correctly' do + subject + + expect(line_item.platform_fee_rate).to eq(rate) + end + end + end +end diff --git a/spec/services/spree_multi_vendor/fees/create_reverse_item_fees_spec.rb b/spec/services/spree_multi_vendor/fees/create_reverse_item_fees_spec.rb new file mode 100644 index 00000000..1d30fd61 --- /dev/null +++ b/spec/services/spree_multi_vendor/fees/create_reverse_item_fees_spec.rb @@ -0,0 +1,55 @@ +require 'spec_helper' + +RSpec.describe SpreeMultiVendor::Fees::CreateReverseItemFees do + subject { described_class.new(line_item: line_item, inventory_unit: inventory_unit).call } + + let(:return_item) { create(:return_item) } + let(:inventory_unit) { return_item.inventory_unit } + let(:line_item) { inventory_unit.line_item } + let(:item_price) { 50.0 } + + describe '#call' do + let(:platform_fee_total) { 15.0 } + + before do + line_item.order.update!(platform_fee_reverse_total: 8.5) + line_item.update!( + price: item_price, + platform_fee_rate: 10.0, + platform_fee_per_unit: 5.0, + platform_fee_total: platform_fee_total, + platform_fee_reverse_total: 8.5 + ) + + inventory_unit.update!(quantity: 3) + end + + it 'creates item reverse platform fee' do + expect { subject }.to change { line_item.marketplace_commissions.count }.by(1) + + reverse_fee = line_item.marketplace_commissions.last + + expect(reverse_fee.amount).to eq 15.0 + expect(reverse_fee.label).to eq Spree::MarketplaceCommission::REVERSE_ITEM_LABEL + expect(reverse_fee.rate).to eq 10.0 + + expect(line_item.reload.platform_fee_reverse_total).to eq(23.5) + expect(line_item.order.reload.platform_fee_reverse_total).to eq(23.5) + end + + it 'does not adjust line item platform fee total' do + subject + expect(line_item.reload.platform_fee_total).to eq(15) + end + + + context 'if line item platform fee is zero' do + let(:platform_fee_total) { 0 } + + it 'does not create reverse platform fee' do + expect { subject }.not_to change { line_item.marketplace_commissions.count } + expect(subject).to eq nil + end + end + end +end diff --git a/spec/services/spree_multi_vendor/fees/create_reverse_order_fee_spec.rb b/spec/services/spree_multi_vendor/fees/create_reverse_order_fee_spec.rb new file mode 100644 index 00000000..80cf2f80 --- /dev/null +++ b/spec/services/spree_multi_vendor/fees/create_reverse_order_fee_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' + +RSpec.describe SpreeMultiVendor::Fees::CreateReverseOrderFee do + describe '#call' do + subject { described_class.new(order: order, refunded_amount: refunded_amount).call } + + let(:order) { create(:order, platform_fee_total: 20) } + let(:refunded_amount) { 10 } + + before do + order.update!( + total: 100.0, + platform_fee_reverse_total: 3.5 + ) + end + + it 'creates reversed platform fee linked to the order' do + expect { subject }.to change { order.marketplace_commissions.count }.by(1) + + expect(subject.amount).to eq(2.0) + expect(order.reload.platform_fee_reverse_total).to eq(5.5) + end + + it 'does not adjust line item platform fee total' do + subject + expect(order.platform_fee_total).to eq(20) + end + + context 'if order platform fee is 0.0' do + let(:order) { create(:order, platform_fee_total: 0.0) } + + it 'does not create reverse platform fee' do + expect { subject }.not_to change { order.marketplace_commissions.count } + expect(subject).to eq nil + end + end + + context 'when trying to refund more commission than allowed' do + before do + order.update!(platform_fee_reverse_total: 18.5) + end + + it 'creates reversed platform fee linked to the order' do + expect { subject }.to change { order.marketplace_commissions.count }.by(1) + + expect(subject.amount).to eq(1.5) + expect(order.reload.platform_fee_reverse_total).to eq(20.0) + end + end + end +end diff --git a/spec/services/spree_multi_vendor/store_credits/calculate_vendor_store_credit_spec.rb b/spec/services/spree_multi_vendor/store_credits/calculate_vendor_store_credit_spec.rb new file mode 100644 index 00000000..22b0f5db --- /dev/null +++ b/spec/services/spree_multi_vendor/store_credits/calculate_vendor_store_credit_spec.rb @@ -0,0 +1,118 @@ +require 'spec_helper' + +RSpec.describe SpreeMultiVendor::StoreCredits::CalculateVendorStoreCredit do + subject(:calculate_vendor_store_credit) { described_class.call(item_total: item_total, order: order, store_credit_total: store_credit_total) } + + let(:result) { calculate_vendor_store_credit.value } + let(:store_credit_total) { nil } + + let(:store) { Spree::Store.default } + let(:vendors) { create_list(:vendor, 2) } + + let(:order) { create(:order, store: store) } + + let(:product1) { create(:product_in_stock, vendor: vendors[0], price: product1_price) } + let(:product2) { create(:product_in_stock, vendor: vendors[1], price: product2_price) } + + let(:product1_price) { 40 } + let(:product2_price) { 30 } + + before do + create(:line_item, order: order, product: product1, price: product1_price, quantity: 2) + create(:line_item, order: order, product: product2, price: product2_price, quantity: 4) + + order.update_with_updater! + end + + context 'with store credit payment' do + before do + create(:store_credit_payment, order: order, amount: 60) + end + + context 'for a partial item total' do + let(:item_total) { 80 } + + it { is_expected.to be_success } + + it 'calculates vendor store credit proportionally' do + # vendor_store_credit = (80 / 200) * 60 = 24 + expect(result).to eq(24) + end + end + + context 'when item total is zero' do + let(:item_total) { 0 } + + it { is_expected.to be_success } + + it 'returns zero' do + expect(result).to eq(0) + end + end + end + + context 'with no store credit payment' do + let(:item_total) { 80 } + + it { is_expected.to be_success } + + it 'returns zero' do + expect(result).to eq(0) + end + end + + context 'when custom store credit total is passed' do + let(:store_credit_total) { 40 } + + context 'for full item total' do + let(:item_total) { 200 } + + it { is_expected.to be_success } + + it 'calculates vendor store credit using custom total' do + # vendor_store_credit = (200 / 200) * 40 = 40 + expect(result).to eq(40) + end + end + + context 'for partial item total' do + let(:item_total) { 80 } + + it { is_expected.to be_success } + + it 'calculates vendor store credit proportionally using custom total' do + # vendor_store_credit = (80 / 200) * 40 = 16 + expect(result).to eq(16) + end + end + + context 'when custom store credit total is zero' do + let(:item_total) { 80 } + let(:store_credit_total) { 0 } + + it { is_expected.to be_success } + + it 'returns zero' do + expect(result).to eq(0) + end + end + end + + context 'when calculation results in many decimal places' do + let(:product3) { create(:product_in_stock, vendor: vendors[1], price: product3_price) } + let(:product3_price) { 33.33 } + + before do + create(:line_item, order: order, product: product3, price: product3_price, quantity: 3) + order.reload.update_with_updater! + end + + let(:item_total) { 99.99 } + let(:store_credit_total) { 17.89 } + + it 'rounds to 2 decimal places' do + # vendor_store_credit = (99.99 / 299.99) * 17.89 = 5.96292... + expect(result).to eq(5.96) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 4e50745b..c255db3f 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,13 +1,40 @@ - # Configure Rails Environment +# Configure Rails Environment ENV['RAILS_ENV'] = 'test' +require 'dotenv/load' + require File.expand_path('../dummy/config/environment.rb', __FILE__) -require 'shoulda/matchers' require 'spree_dev_tools/rspec/spec_helper' +require 'spree_multi_vendor/factories' + +require 'cancan/matchers' +require 'spree/testing_support/ability_helpers' + +require 'spree/testing_support/next_instance_of' +require 'spree/testing_support/capybara_config' + +require 'spree/admin/testing_support/capybara_utils' + +require 'spree_multi_vendor/testing_support/authorization_helpers' + # Requires supporting ruby files with custom matchers and macros, etc, # in spec/support/ and its subdirectories. Dir[File.join(File.dirname(__FILE__), 'support/**/*.rb')].sort.each { |f| require f } -ActiveJob::Base.queue_adapter = :inline \ No newline at end of file +def json_response + case body = JSON.parse(response.body) + when Hash + body.with_indifferent_access + when Array + body + end +end + +RSpec.configure do |config| + config.include Spree::Admin::TestingSupport::CapybaraUtils + + config.example_status_persistence_file_path = '.rspec_status' + Capybara.test_id = 'data-test-id' +end diff --git a/spec/support/shoulda_matchers.rb b/spec/support/shoulda_matchers.rb deleted file mode 100644 index 7d045f35..00000000 --- a/spec/support/shoulda_matchers.rb +++ /dev/null @@ -1,6 +0,0 @@ -Shoulda::Matchers.configure do |config| - config.integrate do |with| - with.test_framework :rspec - with.library :rails - end -end diff --git a/spec/support/vendor_current_order.rb b/spec/support/vendor_current_order.rb new file mode 100644 index 00000000..2a086c01 --- /dev/null +++ b/spec/support/vendor_current_order.rb @@ -0,0 +1,87 @@ +shared_context 'creates vendor order with line item' do + let!(:line_item) { create(:vendor_line_item, order: order, currency: currency, vendor: vendor) } + let!(:headers) { headers_bearer } + + before do + order.reload + ensure_order_totals + end +end + +shared_context 'vendor order with a physical line item' do + include_context 'creates vendor order with line item' +end + +shared_context 'vendor order with a digital line item' do + let!(:digital) { create(:digital, variant: create(:product_in_stock, vendor: vendor).default_variant) } + let!(:variant_digital) { digital.variant } + let!(:line_item) { create(:vendor_line_item, variant: variant_digital, order: order, currency: currency) } + let!(:headers) { headers_bearer } + + before do + order.reload + ensure_order_totals + end +end + +shared_context 'vendor order with a physical and digital line item' do + let!(:digital) { create(:digital, variant: create(:product_in_stock, vendor: vendor).default_variant) } + let!(:variant_digital) { digital.variant } + let!(:digital_line_item) { create(:vendor_line_item, vendor: vendor, variant: variant_digital, order: order, currency: currency) } + let!(:physical_line_item) { create(:vendor_line_item, vendor: vendor, order: order, currency: currency) } + + let!(:headers) { headers_bearer } + + before do + order.reload + ensure_order_totals + end +end + +shared_context 'creates vendor guest order with guest token' do + let(:guest_token) { 'guest_token' } + let!(:order) { create(:order, token: guest_token, store: store, currency: currency) } + let!(:line_item) { create(:vendor_line_item, order: order, currency: currency, vendor: vendor) } + let!(:headers) { headers_order_token } + + before { ensure_order_totals } +end + +shared_examples 'returns valid cart JSON' do + it 'returns a valid cart JSON response' do + order.reload + expect(json_response['data']).to be_present + expect(json_response['data']).to have_id(order.id.to_s) + expect(json_response['data']).to have_type('cart') + expect(json_response['data']).to have_attribute(:number).with_value(order.number) + expect(json_response['data']).to have_attribute(:state).with_value(order.state) + expect(json_response['data']).to have_attribute(:payment_state).with_value(order.payment_state) + expect(json_response['data']).to have_attribute(:shipment_state).with_value(order.shipment_state) + expect(json_response['data']).to have_attribute(:token).with_value(order.token) + expect(json_response['data']).to have_attribute(:total).with_value(order.total.to_s) + expect(json_response['data']).to have_attribute(:item_total).with_value(order.item_total.to_s) + expect(json_response['data']).to have_attribute(:ship_total).with_value(order.ship_total.to_s) + expect(json_response['data']).to have_attribute(:adjustment_total).with_value(order.adjustment_total.to_s) + expect(json_response['data']).to have_attribute(:included_tax_total).with_value(order.included_tax_total.to_s) + expect(json_response['data']).to have_attribute(:additional_tax_total).with_value(order.additional_tax_total.to_s) + expect(json_response['data']).to have_attribute(:display_additional_tax_total).with_value(order.display_additional_tax_total.to_s) + expect(json_response['data']).to have_attribute(:display_included_tax_total).with_value(order.display_included_tax_total.to_s) + expect(json_response['data']).to have_attribute(:tax_total).with_value(order.tax_total.to_s) + expect(json_response['data']).to have_attribute(:currency).with_value(order.currency.to_s) + expect(json_response['data']).to have_attribute(:email).with_value(order.email) + expect(json_response['data']).to have_attribute(:display_item_total).with_value(order.display_item_total.to_s) + expect(json_response['data']).to have_attribute(:display_ship_total).with_value(order.display_ship_total.to_s) + expect(json_response['data']).to have_attribute(:display_adjustment_total).with_value(order.display_adjustment_total.to_s) + expect(json_response['data']).to have_attribute(:display_tax_total).with_value(order.display_tax_total.to_s) + expect(json_response['data']).to have_attribute(:item_count).with_value(order.item_count) + expect(json_response['data']).to have_attribute(:special_instructions).with_value(order.special_instructions) + expect(json_response['data']).to have_attribute(:promo_total).with_value(order.promo_total.to_s) + expect(json_response['data']).to have_attribute(:display_promo_total).with_value(order.display_promo_total.to_s) + expect(json_response['data']).to have_attribute(:display_total).with_value(order.display_total.to_s) + expect(json_response['data']).to have_attribute(:pre_tax_item_amount).with_value(order.pre_tax_item_amount.to_s) + expect(json_response['data']).to have_attribute(:display_pre_tax_item_amount).with_value(order.display_pre_tax_item_amount.to_s) + expect(json_response['data']).to have_attribute(:pre_tax_total).with_value(order.pre_tax_total.to_s) + expect(json_response['data']).to have_attribute(:display_pre_tax_total).with_value(order.display_pre_tax_total.to_s) + expect(json_response['data']).to have_relationships(:user, :line_items, :variants, :billing_address, :shipping_address, :payments, :shipments, :promotions) + end +end diff --git a/spec/zeitwerk_spec.rb b/spec/zeitwerk_spec.rb new file mode 100644 index 00000000..59b6c709 --- /dev/null +++ b/spec/zeitwerk_spec.rb @@ -0,0 +1,9 @@ +require 'spec_helper' + +describe 'Zeitwerk' do + it 'eager loads all files' do + expect do + Zeitwerk::Loader.eager_load_all + end.not_to raise_error + end +end diff --git a/spree_multi_vendor.gemspec b/spree_multi_vendor.gemspec index b0c473a6..a9e49e20 100644 --- a/spree_multi_vendor.gemspec +++ b/spree_multi_vendor.gemspec @@ -7,36 +7,25 @@ require 'spree_multi_vendor/version' Gem::Specification.new do |s| s.platform = Gem::Platform::RUBY s.name = 'spree_multi_vendor' - s.version = SpreeMultiVendor.version - s.summary = 'Spree Commerce multi vendor marketplace extension' - s.description = 'Spree Commerce multi vendor marketplace extension' + s.version = SpreeMultiVendor::VERSION + s.summary = "Spree Commerce Multi vendor Extension" s.required_ruby_version = '>= 3.0' - s.author = 'Spark Solutions' - s.email = 'we@sparksolutions.co' - s.homepage = 'https://spreecommerce.org/use-cases/multi-vendor-marketplace-demo/' - s.license = 'BSD-3-Clause' + s.authors = ['Vendo Connect Inc.', 'Vendo Sp. z o.o.'] + s.email = 'hello@spreecommerce.org' + s.homepage = 'https://github.com/spree/spree_multi_vendor' - s.metadata = { - 'bug_tracker_uri' => 'https://github.com/spree-contrib/spree_multi_vendor/issues', - 'changelog_uri' => "https://github.com/spree-contrib/spree_multi_vendor/releases/tag/v#{s.version}", - 'documentation_uri' => 'https://guides.spreecommerce.org/', - 'source_code_uri' => "https://github.com/spree-contrib/spree_multi_vendor/tree/v#{s.version}", - } - - s.files = `git ls-files`.split("\n") - s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") + s.files = Dir["{app,config,db,lib,vendor}/**/*", "LICENSE.md", "Rakefile", "README.md"].reject { |f| f.match(/^spec/) && !f.match(/^spec\/fixtures/) } s.require_path = 'lib' s.requirements << 'none' - spree_version = '>= 4.7.0' + spree_version = '>= 5.2.0' s.add_dependency 'spree', spree_version - s.add_dependency 'spree_backend', spree_version - s.add_dependency 'spree_emails', spree_version - s.add_dependency 'spree_extension' + s.add_dependency 'spree_admin', spree_version - s.add_dependency 'deface', '~> 1.0' + s.add_dependency 'spree_extension' - s.add_development_dependency 'shoulda-matchers' + s.add_development_dependency 'email_spec' s.add_development_dependency 'spree_dev_tools' + s.add_development_dependency 'timecop' end diff --git a/vendor/javascript/.keep b/vendor/javascript/.keep new file mode 100644 index 00000000..e69de29b diff --git a/vendor/stylesheets/.keep b/vendor/stylesheets/.keep new file mode 100644 index 00000000..e69de29b