solidus_shipstation 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +2 -0
  3. data/.circleci/config.yml +41 -0
  4. data/.gem_release.yml +5 -0
  5. data/.github/stale.yml +17 -0
  6. data/.github_changelog_generator +2 -0
  7. data/.gitignore +20 -0
  8. data/.rspec +2 -0
  9. data/.rubocop.yml +13 -0
  10. data/.rubocop_todo.yml +40 -0
  11. data/CHANGELOG.md +36 -0
  12. data/Gemfile +33 -0
  13. data/LICENSE +26 -0
  14. data/README.md +208 -0
  15. data/Rakefile +6 -0
  16. data/app/assets/javascripts/spree/backend/solidus_shipstation.js +2 -0
  17. data/app/assets/javascripts/spree/frontend/solidus_shipstation.js +2 -0
  18. data/app/assets/stylesheets/spree/backend/solidus_shipstation.css +4 -0
  19. data/app/assets/stylesheets/spree/frontend/solidus_shipstation.css +4 -0
  20. data/app/controllers/spree/shipstation_controller.rb +45 -0
  21. data/app/decorators/models/solidus_shipstation/spree/shipment_decorator.rb +33 -0
  22. data/app/helpers/solidus_shipstation/export_helper.rb +32 -0
  23. data/app/jobs/solidus_shipstation/api/schedule_shipment_syncs_job.rb +19 -0
  24. data/app/jobs/solidus_shipstation/api/sync_shipments_job.rb +41 -0
  25. data/app/queries/solidus_shipstation/shipment/between_query.rb +14 -0
  26. data/app/queries/solidus_shipstation/shipment/exportable_query.rb +24 -0
  27. data/app/queries/solidus_shipstation/shipment/pending_api_sync_query.rb +63 -0
  28. data/app/views/spree/shipstation/export.xml.builder +58 -0
  29. data/bin/console +17 -0
  30. data/bin/rails +7 -0
  31. data/bin/rails-engine +13 -0
  32. data/bin/rails-sandbox +16 -0
  33. data/bin/rake +7 -0
  34. data/bin/sandbox +86 -0
  35. data/bin/setup +8 -0
  36. data/config/locales/en.yml +5 -0
  37. data/config/routes.rb +6 -0
  38. data/db/migrate/20210220093010_add_shipstation_api_sync_fields.rb +9 -0
  39. data/lib/generators/solidus_shipstation/install/install_generator.rb +27 -0
  40. data/lib/generators/solidus_shipstation/install/templates/initializer.rb +62 -0
  41. data/lib/solidus_shipstation.rb +16 -0
  42. data/lib/solidus_shipstation/api/batch_syncer.rb +70 -0
  43. data/lib/solidus_shipstation/api/client.rb +38 -0
  44. data/lib/solidus_shipstation/api/rate_limited_error.rb +23 -0
  45. data/lib/solidus_shipstation/api/request_error.rb +33 -0
  46. data/lib/solidus_shipstation/api/request_runner.rb +50 -0
  47. data/lib/solidus_shipstation/api/shipment_serializer.rb +84 -0
  48. data/lib/solidus_shipstation/api/threshold_verifier.rb +28 -0
  49. data/lib/solidus_shipstation/configuration.rb +44 -0
  50. data/lib/solidus_shipstation/engine.rb +19 -0
  51. data/lib/solidus_shipstation/errors.rb +23 -0
  52. data/lib/solidus_shipstation/shipment_notice.rb +58 -0
  53. data/lib/solidus_shipstation/testing_support/factories.rb +4 -0
  54. data/lib/solidus_shipstation/version.rb +5 -0
  55. data/solidus_shipstation.gemspec +40 -0
  56. data/spec/controllers/spree/shipstation_controller_spec.rb +103 -0
  57. data/spec/fixtures/shipstation_xml_schema.xsd +171 -0
  58. data/spec/jobs/solidus_shipstation/api/schedule_shipment_syncs_job_spec.rb +32 -0
  59. data/spec/jobs/solidus_shipstation/api/sync_shipments_job_spec.rb +102 -0
  60. data/spec/lib/solidus_shipstation/api/batch_syncer_spec.rb +229 -0
  61. data/spec/lib/solidus_shipstation/api/client_spec.rb +120 -0
  62. data/spec/lib/solidus_shipstation/api/rate_limited_error_spec.rb +21 -0
  63. data/spec/lib/solidus_shipstation/api/request_error_spec.rb +20 -0
  64. data/spec/lib/solidus_shipstation/api/request_runner_spec.rb +64 -0
  65. data/spec/lib/solidus_shipstation/api/shipment_serializer_spec.rb +12 -0
  66. data/spec/lib/solidus_shipstation/api/threshold_verifier_spec.rb +61 -0
  67. data/spec/lib/solidus_shipstation/shipment_notice_spec.rb +111 -0
  68. data/spec/lib/solidus_shipstation_spec.rb +9 -0
  69. data/spec/models/spree/shipment_spec.rb +49 -0
  70. data/spec/queries/solidus_shipstation/shipment/between_query_spec.rb +53 -0
  71. data/spec/queries/solidus_shipstation/shipment/exportable_query_spec.rb +53 -0
  72. data/spec/queries/solidus_shipstation/shipment/pending_api_sync_query_spec.rb +37 -0
  73. data/spec/spec_helper.rb +31 -0
  74. data/spec/support/configuration_helper.rb +13 -0
  75. data/spec/support/controllers.rb +1 -0
  76. data/spec/support/webmock.rb +3 -0
  77. data/spec/support/xsd.rb +5 -0
  78. metadata +248 -0
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'solidus_dev_support/rake_tasks'
4
+ SolidusDevSupport::RakeTasks.install
5
+
6
+ task default: 'extension:specs'
@@ -0,0 +1,2 @@
1
+ // Placeholder manifest file.
2
+ // the installer will append this file to the app vendored assets here: vendor/assets/javascripts/spree/backend/all.js'
@@ -0,0 +1,2 @@
1
+ // Placeholder manifest file.
2
+ // the installer will append this file to the app vendored assets here: vendor/assets/javascripts/spree/frontend/all.js'
@@ -0,0 +1,4 @@
1
+ /*
2
+ Placeholder manifest file.
3
+ the installer will append this file to the app vendored assets here: 'vendor/assets/stylesheets/spree/backend/all.css'
4
+ */
@@ -0,0 +1,4 @@
1
+ /*
2
+ Placeholder manifest file.
3
+ the installer will append this file to the app vendored assets here: 'vendor/assets/stylesheets/spree/frontend/all.css'
4
+ */
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class ShipstationController < Spree::BaseController
5
+ protect_from_forgery with: :null_session, only: :shipnotify
6
+
7
+ before_action :authenticate_shipstation
8
+
9
+ def export
10
+ @shipments = SolidusShipstation::Shipment::ExportableQuery.apply(Spree::Shipment.all)
11
+ @shipments = SolidusShipstation::Shipment::BetweenQuery.apply(
12
+ @shipments,
13
+ from: date_param(:start_date),
14
+ to: date_param(:end_date),
15
+ )
16
+ @shipments = @shipments.page(params[:page]).per(50)
17
+
18
+ respond_to do |format|
19
+ format.xml { render layout: false }
20
+ end
21
+ end
22
+
23
+ def shipnotify
24
+ SolidusShipstation::ShipmentNotice.from_payload(params.to_unsafe_h).apply
25
+ head :ok
26
+ rescue SolidusShipstation::Error => e
27
+ head :bad_request
28
+ end
29
+
30
+ private
31
+
32
+ def date_param(name)
33
+ return if params[name].blank?
34
+
35
+ Time.strptime("#{params[name]} UTC", '%m/%d/%Y %H:%M %Z')
36
+ end
37
+
38
+ def authenticate_shipstation
39
+ authenticate_or_request_with_http_basic do |username, password|
40
+ username == SolidusShipstation.configuration.username &&
41
+ password == SolidusShipstation.configuration.password
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusShipstation
4
+ module Spree
5
+ module ShipmentDecorator
6
+ def self.prepended(base)
7
+ base.singleton_class.prepend ClassMethods
8
+ end
9
+
10
+ module ClassMethods
11
+ def exportable
12
+ ::Spree::Deprecation.warn <<~DEPRECATION
13
+ `Spree::Shipment.exportable` is deprecated and will be removed in a future version
14
+ of solidus_shipstation. Please use `SolidusShipstation::Shipment::ExportableQuery.apply`.
15
+ DEPRECATION
16
+
17
+ SolidusShipstation::Shipment::ExportableQuery.apply(self)
18
+ end
19
+
20
+ def between(from, to)
21
+ ::Spree::Deprecation.warn <<~DEPRECATION
22
+ `Spree::Shipment.between` is deprecated and will be removed in a future version
23
+ of solidus_shipstation. Please use `SolidusShipstation::Shipment::BetweenQuery.apply`.
24
+ DEPRECATION
25
+
26
+ SolidusShipstation::Shipment::BetweenQuery.apply(self, from: from, to: to)
27
+ end
28
+ end
29
+
30
+ ::Spree::Shipment.prepend self
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'builder'
4
+
5
+ module SolidusShipstation
6
+ module ExportHelper
7
+ DATE_FORMAT = '%m/%d/%Y %H:%M'
8
+
9
+ # rubocop:disable all
10
+ def self.address(xml, order, type)
11
+ name = "#{type.to_s.titleize}To"
12
+ address = order.send("#{type}_address")
13
+
14
+ xml.__send__(name) {
15
+ xml.Name address.respond_to?(:name) ? address.name : address.full_name
16
+ xml.Company address.company
17
+
18
+ if type == :ship
19
+ xml.Address1 address.address1
20
+ xml.Address2 address.address2
21
+ xml.City address.city
22
+ xml.State address.state ? address.state.abbr : address.state_name
23
+ xml.PostalCode address.zipcode
24
+ xml.Country address.country.iso
25
+ end
26
+
27
+ xml.Phone address.phone
28
+ }
29
+ end
30
+ # rubocop:enable all
31
+ end
32
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusShipstation
4
+ module Api
5
+ class ScheduleShipmentSyncsJob < ApplicationJob
6
+ queue_as :default
7
+
8
+ def perform
9
+ shipments = SolidusShipstation::Shipment::PendingApiSyncQuery.apply(::Spree::Shipment.all)
10
+
11
+ shipments.find_in_batches(batch_size: SolidusShipstation.config.api_batch_size) do |batch|
12
+ SyncShipmentsJob.perform_later(batch.to_a)
13
+ end
14
+ rescue StandardError => e
15
+ SolidusShipstation.config.error_handler.call(e, {})
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusShipstation
4
+ module Api
5
+ class SyncShipmentsJob < ApplicationJob
6
+ queue_as :default
7
+
8
+ def perform(shipments)
9
+ shipments = select_shipments(shipments)
10
+ return if shipments.empty?
11
+
12
+ sync_shipments(shipments)
13
+ rescue RateLimitedError => e
14
+ self.class.set(wait: e.retry_in).perform_later
15
+ rescue StandardError => e
16
+ SolidusShipstation.config.error_handler.call(e, {})
17
+ end
18
+
19
+ private
20
+
21
+ def select_shipments(shipments)
22
+ shipments.select do |shipment|
23
+ if ThresholdVerifier.call(shipment)
24
+ true
25
+ else
26
+ ::Spree::Event.fire(
27
+ 'solidus_shipstation.api.sync_skipped',
28
+ shipment: shipment,
29
+ )
30
+
31
+ false
32
+ end
33
+ end
34
+ end
35
+
36
+ def sync_shipments(shipments)
37
+ BatchSyncer.from_config.call(shipments)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusShipstation
4
+ module Shipment
5
+ class BetweenQuery
6
+ def self.apply(scope, from:, to:)
7
+ scope.joins(:order).where(<<~SQL.squish, from: from, to: to)
8
+ (spree_shipments.updated_at > :from AND spree_shipments.updated_at < :to) OR
9
+ (spree_orders.updated_at > :from AND spree_orders.updated_at < :to)
10
+ SQL
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusShipstation
4
+ module Shipment
5
+ class ExportableQuery
6
+ def self.apply(scope)
7
+ scope = scope
8
+ .order(:updated_at)
9
+ .joins(:order)
10
+ .merge(::Spree::Order.complete)
11
+
12
+ unless SolidusShipstation.configuration.capture_at_notification
13
+ scope = scope.where(spree_shipments: { state: ['ready', 'canceled'] })
14
+ end
15
+
16
+ unless SolidusShipstation.configuration.export_canceled_shipments
17
+ scope = scope.where.not(spree_shipments: { state: 'canceled' })
18
+ end
19
+
20
+ scope
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusShipstation
4
+ module Shipment
5
+ class PendingApiSyncQuery
6
+ SQLITE_CONDITION = <<~SQL.squish
7
+ (
8
+ spree_shipments.shipstation_synced_at IS NULL
9
+ OR (
10
+ spree_shipments.shipstation_synced_at IS NOT NULL
11
+ AND spree_shipments.shipstation_synced_at < spree_orders.updated_at
12
+ )
13
+ ) AND ((JULIANDAY(CURRENT_TIMESTAMP) - JULIANDAY(spree_orders.updated_at)) * 86400.0) < :threshold
14
+ SQL
15
+
16
+ POSTGRES_CONDITION = <<~SQL.squish
17
+ (
18
+ spree_shipments.shipstation_synced_at IS NULL
19
+ OR (
20
+ spree_shipments.shipstation_synced_at IS NOT NULL
21
+ AND spree_shipments.shipstation_synced_at < spree_orders.updated_at
22
+ )
23
+ ) AND (EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - spree_orders.updated_at))) < :threshold
24
+ SQL
25
+
26
+ MYSQL2_CONDITION = <<~SQL.squish
27
+ (
28
+ spree_shipments.shipstation_synced_at IS NULL
29
+ OR (
30
+ spree_shipments.shipstation_synced_at IS NOT NULL
31
+ AND spree_shipments.shipstation_synced_at < spree_orders.updated_at
32
+ )
33
+ ) AND (UNIX_TIMESTAMP() - UNIX_TIMESTAMP(spree_orders.updated_at)) < :threshold
34
+ SQL
35
+
36
+ class << self
37
+ def apply(scope)
38
+ scope
39
+ .joins(:order)
40
+ .merge(::Spree::Order.complete)
41
+ .where(condition_for_adapter, threshold: SolidusShipstation.config.api_sync_threshold / 1.second)
42
+ end
43
+
44
+ private
45
+
46
+ def condition_for_adapter
47
+ db_adapter = ActiveRecord::Base.connection.adapter_name.downcase
48
+
49
+ case db_adapter
50
+ when /sqlite/
51
+ SQLITE_CONDITION
52
+ when /postgres/
53
+ POSTGRES_CONDITION
54
+ when /mysql2/
55
+ MYSQL2_CONDITION
56
+ else
57
+ fail "ShipStation API sync not supported for DB adapter #{db_adapter}!"
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ xml = Builder::XmlMarkup.new
4
+ xml.instruct!
5
+ xml.Orders(pages: (@shipments.total_count / 50.0).ceil) {
6
+ @shipments.each do |shipment|
7
+ order = shipment.order
8
+
9
+ xml.Order {
10
+ xml.OrderID shipment.id
11
+ xml.OrderNumber shipment.number # do not use shipment.order.number as this presents lookup issues
12
+ xml.OrderDate order.completed_at.strftime(SolidusShipstation::ExportHelper::DATE_FORMAT)
13
+ xml.OrderStatus shipment.state
14
+ xml.LastModified [order.completed_at, shipment.updated_at].max.strftime(SolidusShipstation::ExportHelper::DATE_FORMAT)
15
+ xml.ShippingMethod shipment.shipping_method.try(:name)
16
+ xml.OrderTotal order.total
17
+ xml.TaxAmount order.tax_total
18
+ xml.ShippingAmount order.ship_total
19
+ xml.CustomField1 order.number
20
+
21
+ # if order.gift?
22
+ # xml.Gift
23
+ # xml.GiftMessage
24
+ # end
25
+
26
+ xml.Customer {
27
+ xml.CustomerCode order.email.slice(0, 50)
28
+ SolidusShipstation::ExportHelper.address(xml, order, :bill)
29
+ SolidusShipstation::ExportHelper.address(xml, order, :ship)
30
+ }
31
+ xml.Items {
32
+ shipment.line_items.each do |line|
33
+ variant = line.variant
34
+ xml.Item {
35
+ xml.SKU variant.sku
36
+ xml.Name [variant.product.name, variant.options_text].join(' ')
37
+ xml.ImageUrl variant.images.first.try(:attachment).try(:url)
38
+ xml.Weight variant.weight.to_f
39
+ xml.WeightUnits SolidusShipstation.configuration.weight_units
40
+ xml.Quantity line.quantity
41
+ xml.UnitPrice line.price
42
+
43
+ if variant.option_values.present?
44
+ xml.Options {
45
+ variant.option_values.each do |value|
46
+ xml.Option {
47
+ xml.Name value.option_type.presentation
48
+ xml.Value value.name
49
+ }
50
+ end
51
+ }
52
+ end
53
+ }
54
+ end
55
+ }
56
+ }
57
+ end
58
+ }
data/bin/console ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # frozen_string_literal: true
4
+
5
+ require "bundler/setup"
6
+ require "solidus_shipstation"
7
+
8
+ # You can add fixtures and/or initialization code here to make experimenting
9
+ # with your gem easier. You can also use a different console, if you like.
10
+ $LOAD_PATH.unshift(*Dir["#{__dir__}/../app/*"])
11
+
12
+ # (If you use this, don't forget to add pry to your Gemfile!)
13
+ # require "pry"
14
+ # Pry.start
15
+
16
+ require "irb"
17
+ IRB.start(__FILE__)
data/bin/rails ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ if %w[g generate].include? ARGV.first
4
+ exec "#{__dir__}/rails-engine", *ARGV
5
+ else
6
+ exec "#{__dir__}/rails-sandbox", *ARGV
7
+ end
data/bin/rails-engine ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+ # This command will automatically be run when you run "rails" with Rails gems
3
+ # installed from the root of your application.
4
+
5
+ ENGINE_ROOT = File.expand_path('..', __dir__)
6
+ ENGINE_PATH = File.expand_path('../lib/solidus_shipstation/engine', __dir__)
7
+
8
+ # Set up gems listed in the Gemfile.
9
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
10
+ require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE'])
11
+
12
+ require 'rails/all'
13
+ require 'rails/engine/commands'
data/bin/rails-sandbox ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ app_root = 'sandbox'
4
+
5
+ unless File.exist? "#{app_root}/bin/rails"
6
+ warn 'Creating the sandbox app...'
7
+ Dir.chdir "#{__dir__}/.." do
8
+ system "#{__dir__}/sandbox" or begin
9
+ warn 'Automatic creation of the sandbox app failed'
10
+ exit 1
11
+ end
12
+ end
13
+ end
14
+
15
+ Dir.chdir app_root
16
+ exec 'bin/rails', *ARGV
data/bin/rake ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "rubygems"
5
+ require "bundler/setup"
6
+
7
+ load Gem.bin_path("rake", "rake")
data/bin/sandbox ADDED
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -e
4
+
5
+ case "$DB" in
6
+ postgres|postgresql)
7
+ RAILSDB="postgresql"
8
+ ;;
9
+ mysql)
10
+ RAILSDB="mysql"
11
+ ;;
12
+ sqlite|'')
13
+ RAILSDB="sqlite3"
14
+ ;;
15
+ *)
16
+ echo "Invalid DB specified: $DB"
17
+ exit 1
18
+ ;;
19
+ esac
20
+
21
+ if [ ! -z $SOLIDUS_BRANCH ]
22
+ then
23
+ BRANCH=$SOLIDUS_BRANCH
24
+ else
25
+ BRANCH="master"
26
+ fi
27
+
28
+ extension_name="solidus_shipstation"
29
+
30
+ # Stay away from the bundler env of the containing extension.
31
+ function unbundled {
32
+ ruby -rbundler -e'b = proc {system *ARGV}; Bundler.respond_to?(:with_unbundled_env) ? Bundler.with_unbundled_env(&b) : Bundler.with_clean_env(&b)' -- $@
33
+ }
34
+
35
+ rm -rf ./sandbox
36
+ unbundled bundle exec rails new sandbox --database="$RAILSDB" \
37
+ --skip-bundle \
38
+ --skip-git \
39
+ --skip-keeps \
40
+ --skip-rc \
41
+ --skip-spring \
42
+ --skip-test \
43
+ --skip-javascript
44
+
45
+ if [ ! -d "sandbox" ]; then
46
+ echo 'sandbox rails application failed'
47
+ exit 1
48
+ fi
49
+
50
+ cd ./sandbox
51
+ cat <<RUBY >> Gemfile
52
+ gem 'solidus', github: 'solidusio/solidus', branch: '$BRANCH'
53
+ gem 'solidus_auth_devise', '>= 2.1.0'
54
+ gem 'rails-i18n'
55
+ gem 'solidus_i18n'
56
+
57
+ gem '$extension_name', path: '..'
58
+
59
+ group :test, :development do
60
+ platforms :mri do
61
+ gem 'pry-byebug'
62
+ end
63
+ end
64
+ RUBY
65
+
66
+ unbundled bundle install --gemfile Gemfile
67
+
68
+ unbundled bundle exec rake db:drop db:create
69
+
70
+ unbundled bundle exec rails generate solidus:install \
71
+ --auto-accept \
72
+ --user_class=Spree::User \
73
+ --enforce_available_locales=true \
74
+ --with-authentication=false \
75
+ --payment-method=none \
76
+ $@
77
+
78
+ unbundled bundle exec rails generate solidus:auth:install
79
+ unbundled bundle exec rails generate ${extension_name}:install
80
+
81
+ echo
82
+ echo "🚀 Sandbox app successfully created for $extension_name!"
83
+ echo "🚀 Using $RAILSDB and Solidus $BRANCH"
84
+ echo "🚀 Use 'export DB=[postgres|mysql|sqlite]' to control the DB adapter"
85
+ echo "🚀 Use 'export SOLIDUS_BRANCH=<BRANCH-NAME>' to control the Solidus version"
86
+ echo "🚀 This app is intended for test purposes."