spree-shipstation 5.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 (44) hide show
  1. checksums.yaml +7 -0
  2. data/.github/stale.yml +17 -0
  3. data/.github/workflows/lint.yml +24 -0
  4. data/.github/workflows/security.yml +41 -0
  5. data/.github/workflows/test.yml +32 -0
  6. data/.gitignore +23 -0
  7. data/.rspec +3 -0
  8. data/.standard.yml +3 -0
  9. data/Appraisals +20 -0
  10. data/CHANGELOG.md +43 -0
  11. data/CLAUDE.md +95 -0
  12. data/Gemfile +14 -0
  13. data/LICENSE +21 -0
  14. data/README.md +120 -0
  15. data/Rakefile +69 -0
  16. data/app/assets/config/spree_shipstation_manifest.js +1 -0
  17. data/app/assets/images/integration_icons/shipstation-logo.webp +0 -0
  18. data/app/controllers/spree/shipstation_controller.rb +57 -0
  19. data/app/helpers/spree/shipstation/export_helper.rb +53 -0
  20. data/app/models/spree/integrations/shipstation.rb +61 -0
  21. data/app/models/spree/shipment_decorator.rb +28 -0
  22. data/app/presenters/spree/shipstation/export/item_presenter.rb +52 -0
  23. data/app/presenters/spree/shipstation/export/order_presenter.rb +91 -0
  24. data/app/presenters/spree/shipstation/export/weight.rb +45 -0
  25. data/app/views/spree/admin/integrations/forms/_shipstation.html.erb +6 -0
  26. data/app/views/spree/shipstation/export.xml.builder +59 -0
  27. data/bin/rails +8 -0
  28. data/config/initializers/spree.rb +3 -0
  29. data/config/locales/en.yml +15 -0
  30. data/config/routes.rb +6 -0
  31. data/gemfiles/spree_5_2.gemfile +18 -0
  32. data/gemfiles/spree_5_3.gemfile +18 -0
  33. data/gemfiles/spree_5_4.gemfile +18 -0
  34. data/lib/spree/shipstation/engine.rb +32 -0
  35. data/lib/spree/shipstation/errors.rb +25 -0
  36. data/lib/spree/shipstation/factories.rb +5 -0
  37. data/lib/spree/shipstation/shipment_notice.rb +68 -0
  38. data/lib/spree/shipstation/testing_support/factories/shipstation_integration.rb +9 -0
  39. data/lib/spree/shipstation/version.rb +12 -0
  40. data/lib/spree/shipstation.rb +11 -0
  41. data/lib/spree-shipstation.rb +8 -0
  42. data/spec/fixtures/shipstation_xml_schema.xsd +171 -0
  43. data/spree-shipstation.gemspec +36 -0
  44. metadata +140 -0
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module Integrations
5
+ class Shipstation < Spree::Integration
6
+ preference :username, :string
7
+
8
+ validates :preferred_username,
9
+ presence: true,
10
+ length: {minimum: 10, maximum: 30}
11
+
12
+ # Restrict to characters that are safe to send in an HTTP Basic Auth header:
13
+ # a-z, A-Z, 0-9, and . _ @ + -
14
+ validates :preferred_username,
15
+ format: {
16
+ with: /\A[a-zA-Z0-9._@+-]+\z/,
17
+ message: Spree.t("admin.integrations.shipstation.username_chars_error")
18
+ },
19
+ allow_blank: true
20
+
21
+ preference :password, :password
22
+
23
+ validates :preferred_password,
24
+ presence: true,
25
+ length: {minimum: 20, maximum: 60}
26
+
27
+ validates :preferred_password,
28
+ format: {
29
+ with: /[!@#$%^&*(),.?":{}|<>]/,
30
+ message: Spree.t("admin.integrations.shipstation.must_contain_at_least_one_special_character")
31
+ },
32
+ allow_blank: true
33
+
34
+ validates :preferred_password,
35
+ format: {
36
+ with: /[A-Z]/,
37
+ message: Spree.t("admin.integrations.shipstation.must_contain_at_least_one_uppercase_letter")
38
+ },
39
+ allow_blank: true
40
+
41
+ validates :preferred_password,
42
+ format: {
43
+ with: /\d/,
44
+ message: Spree.t("admin.integrations.shipstation.must_contain_at_least_one_number")
45
+ },
46
+ allow_blank: true
47
+
48
+ def self.integration_group
49
+ "Shipping"
50
+ end
51
+
52
+ def self.icon_path
53
+ "integration_icons/shipstation-logo.webp"
54
+ end
55
+
56
+ def self.integration_name
57
+ Spree.t("admin.integrations.shipstation.brand_name")
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module ShipmentDecorator
5
+ def self.prepended(base)
6
+ base.scope :exportable, lambda {
7
+ joins(:order)
8
+ .merge(::Spree::Order.complete)
9
+ .where(state: "ready")
10
+ .order(:updated_at)
11
+ .includes(:order, inventory_units: {line_item: {variant: [:product, :images, {option_values: :option_type}]}})
12
+ }
13
+
14
+ base.scope :between, lambda { |from, to|
15
+ return all if from.nil? && to.nil?
16
+
17
+ range = from..to
18
+
19
+ shipment_match = joins(:order).where(updated_at: range)
20
+ order_match = joins(:order).where(spree_orders: {updated_at: range})
21
+
22
+ shipment_match.or(order_match).distinct
23
+ }
24
+ end
25
+
26
+ ::Spree::Shipment.prepend self
27
+ end
28
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module Shipstation
5
+ module Export
6
+ # Shapes a shipment line item (a line item plus its inventory units) into the
7
+ # values ShipStation's export XML expects for an <Item>.
8
+ class ItemPresenter
9
+ attr_reader :line_item, :units
10
+
11
+ def initialize(line_item, units)
12
+ @line_item = line_item
13
+ @units = units
14
+ end
15
+
16
+ def variant
17
+ @variant ||= line_item.variant
18
+ end
19
+
20
+ def sku
21
+ variant.sku
22
+ end
23
+
24
+ def name
25
+ [variant.product.name, variant.options_text].reject(&:blank?).join(" ")
26
+ end
27
+
28
+ # The Spree::Image to advertise, or nil. URL generation stays in the view
29
+ # because it depends on Rails route/URL helpers.
30
+ def image
31
+ variant.images.first || variant.product.master.images.first
32
+ end
33
+
34
+ def weight
35
+ Weight.from_variant(variant)
36
+ end
37
+
38
+ def quantity
39
+ units.size
40
+ end
41
+
42
+ def unit_price
43
+ line_item.price
44
+ end
45
+
46
+ def option_values
47
+ variant.option_values
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module Shipstation
5
+ module Export
6
+ # Shapes a single Spree::Shipment into the values ShipStation's export XML
7
+ # expects, keeping data-massaging logic out of the Builder template.
8
+ #
9
+ # Note: ShipStation models one "Order" per shipment, so most order-level
10
+ # fields are derived from `shipment.order` while identifiers come from the
11
+ # shipment itself (mirroring the <OrderNumber> = shipment.number contract).
12
+ class OrderPresenter
13
+ attr_reader :shipment
14
+
15
+ def initialize(shipment)
16
+ @shipment = shipment
17
+ end
18
+
19
+ def order
20
+ shipment.order
21
+ end
22
+
23
+ def order_id
24
+ shipment.id
25
+ end
26
+
27
+ def order_number
28
+ shipment.number
29
+ end
30
+
31
+ def order_status
32
+ shipment.state
33
+ end
34
+
35
+ def order_date
36
+ format_date(order.completed_at)
37
+ end
38
+
39
+ def last_modified
40
+ format_date([order.completed_at, shipment.updated_at].compact.max)
41
+ end
42
+
43
+ def shipping_method_name
44
+ shipment.shipping_method&.name
45
+ end
46
+
47
+ def order_total
48
+ order.total
49
+ end
50
+
51
+ def tax_total
52
+ order.tax_total
53
+ end
54
+
55
+ def ship_total
56
+ order.ship_total
57
+ end
58
+
59
+ def custom_field_1
60
+ order.number
61
+ end
62
+
63
+ def customer_code
64
+ order.email&.slice(0, 50)
65
+ end
66
+
67
+ def bill_address
68
+ order.bill_address
69
+ end
70
+
71
+ def ship_address
72
+ order.ship_address
73
+ end
74
+
75
+ def items
76
+ shipment.inventory_units.group_by(&:line_item).filter_map do |line_item, units|
77
+ next unless line_item.variant
78
+
79
+ ItemPresenter.new(line_item, units)
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ def format_date(time)
86
+ time&.strftime(ExportHelper::DATE_FORMAT)
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module Shipstation
5
+ module Export
6
+ # Value object converting a Spree::Variant's weight into the
7
+ # (value, unit-label) pair ShipStation's export XML expects.
8
+ #
9
+ # ShipStation accepts Pounds, Ounces, and Grams; any unrecognised Spree
10
+ # weight unit (including a missing weight) falls back to Grams.
11
+ class Weight
12
+ attr_reader :value, :units
13
+
14
+ def initialize(value:, units:)
15
+ @value = value
16
+ @units = units
17
+ end
18
+
19
+ def self.from_variant(variant)
20
+ amount = (variant.weight || 0.0).to_f
21
+
22
+ case variant.weight_unit
23
+ when "lb"
24
+ new(value: amount, units: "Pounds")
25
+ when "oz"
26
+ new(value: amount, units: "Ounces")
27
+ when "kg"
28
+ new(value: amount * 1000, units: "Grams")
29
+ else
30
+ new(value: amount, units: "Grams")
31
+ end
32
+ end
33
+
34
+ def ==(other)
35
+ other.is_a?(self.class) && value == other.value && units == other.units
36
+ end
37
+ alias_method :eql?, :==
38
+
39
+ def hash
40
+ [value, units].hash
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,6 @@
1
+ <div class="card mb-4">
2
+ <div class="card-body">
3
+ <%= preference_field(@integration, form, 'username', i18n_scope: 'admin.integrations.shipstation') %>
4
+ <%= preference_field(@integration, form, 'password', i18n_scope: 'admin.integrations.shipstation') %>
5
+ </div>
6
+ </div>
@@ -0,0 +1,59 @@
1
+ xml.instruct!
2
+
3
+ xml.Orders(pages: @pagy.pages) do
4
+ @shipments.each do |shipment|
5
+ order = Spree::Shipstation::Export::OrderPresenter.new(shipment)
6
+
7
+ xml.Order do
8
+ xml.OrderID order.order_id
9
+ xml.OrderNumber order.order_number
10
+
11
+ xml.OrderDate order.order_date
12
+ xml.OrderStatus order.order_status
13
+ xml.LastModified order.last_modified
14
+
15
+ xml.ShippingMethod order.shipping_method_name
16
+ xml.OrderTotal order.order_total
17
+ xml.TaxAmount order.tax_total
18
+ xml.ShippingAmount order.ship_total
19
+ xml.CustomField1 order.custom_field_1
20
+
21
+ xml.Customer do
22
+ xml.CustomerCode order.customer_code
23
+ Spree::Shipstation::ExportHelper.bill_address(xml, order.bill_address)
24
+ Spree::Shipstation::ExportHelper.ship_address(xml, order.ship_address)
25
+ end
26
+
27
+ xml.Items do
28
+ order.items.each do |item|
29
+ weight = item.weight
30
+ image = item.image
31
+
32
+ xml.Item do
33
+ xml.SKU item.sku
34
+ xml.Name item.name
35
+
36
+ image_url = image && url_for(image.attachment)
37
+ xml.ImageUrl image_url if image_url
38
+
39
+ xml.Weight weight.value
40
+ xml.WeightUnits weight.units
41
+ xml.Quantity item.quantity
42
+ xml.UnitPrice item.unit_price
43
+
44
+ if item.option_values.present?
45
+ xml.Options do
46
+ item.option_values.each do |value|
47
+ xml.Option do
48
+ xml.Name value.option_type.presentation
49
+ xml.Value value.name
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
data/bin/rails ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # This command will automatically be run when you run "rails" from the root of your extension
3
+
4
+ ENGINE_ROOT = File.expand_path('..', __dir__)
5
+ ENGINE_PATH = File.expand_path('../lib/spree/shipstation/engine', __dir__)
6
+
7
+ require 'rails/all'
8
+ require 'rails/engine/commands'
@@ -0,0 +1,3 @@
1
+ Rails.application.config.after_initialize do
2
+ Rails.application.config.spree.integrations << Spree::Integrations::Shipstation
3
+ end
@@ -0,0 +1,15 @@
1
+ ---
2
+ en:
3
+ spree:
4
+ admin:
5
+ integrations:
6
+ shipstation:
7
+ must_contain_at_least_one_special_character: must contain at least one special character
8
+ must_contain_at_least_one_uppercase_letter: must contain at least one uppercase letter
9
+ must_contain_at_least_one_number: must contain at least one number
10
+ username_chars_error: can only contain letters, numbers, and . _ @ + -
11
+ username: Username
12
+ password: Password
13
+ brand_name: ShipStation
14
+ description: Integrate with ShipStation to automate your shipping workflows from a single dashboard.
15
+ capture_at_notification: Capture payment when shipment notification received from ShipStation
data/config/routes.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ Spree::Core::Engine.add_routes do
4
+ get "/shipstation", to: "shipstation#export"
5
+ post "/shipstation", to: "shipstation#shipnotify"
6
+ end
@@ -0,0 +1,18 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal"
6
+ gem "benchmark"
7
+ gem "brakeman", require: false
8
+ gem "bundler-audit", require: false
9
+ gem "propshaft"
10
+ gem "rails-controller-testing"
11
+ gem "rspec-xsd"
12
+ gem "spree_dev_tools"
13
+ gem "standard"
14
+ gem "sqlite3"
15
+ gem "spree", "~> 5.2.0"
16
+ gem "spree_admin", "~> 5.2.0"
17
+
18
+ gemspec path: "../"
@@ -0,0 +1,18 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal"
6
+ gem "benchmark"
7
+ gem "brakeman", require: false
8
+ gem "bundler-audit", require: false
9
+ gem "propshaft"
10
+ gem "rails-controller-testing"
11
+ gem "rspec-xsd"
12
+ gem "spree_dev_tools"
13
+ gem "standard"
14
+ gem "sqlite3"
15
+ gem "spree", "~> 5.3.0"
16
+ gem "spree_admin", "~> 5.3.0"
17
+
18
+ gemspec path: "../"
@@ -0,0 +1,18 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal"
6
+ gem "benchmark"
7
+ gem "brakeman", require: false
8
+ gem "bundler-audit", require: false
9
+ gem "propshaft"
10
+ gem "rails-controller-testing"
11
+ gem "rspec-xsd"
12
+ gem "spree_dev_tools"
13
+ gem "standard"
14
+ gem "sqlite3"
15
+ gem "spree", "~> 5.4.0"
16
+ gem "spree_admin", "~> 5.4.0"
17
+
18
+ gemspec path: "../"
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module Shipstation
5
+ class Engine < ::Rails::Engine
6
+ require "spree/core"
7
+ isolate_namespace Spree
8
+
9
+ # Deliberately not "spree-shipstation": engine_name generates route helper
10
+ # prefixes and must be a valid Ruby identifier, so it cannot contain a dash.
11
+ engine_name "spree_shipstation"
12
+
13
+ # use rspec for tests
14
+ config.generators do |g|
15
+ g.test_framework :rspec
16
+ end
17
+
18
+ initializer "spree_shipstation.assets" do |app|
19
+ app.config.assets.precompile += %w[spree_shipstation_manifest] if app.config.respond_to?(:assets)
20
+ end
21
+
22
+ def self.activate
23
+ # Three levels up from lib/spree/shipstation/ to reach the gem root.
24
+ Dir.glob(File.join(File.dirname(__FILE__), "../../../app/**/*_decorator*.rb")).sort.each do |c|
25
+ Rails.configuration.cache_classes ? require(c) : load(c)
26
+ end
27
+ end
28
+
29
+ config.to_prepare(&method(:activate).to_proc)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module Shipstation
5
+ class Error < StandardError; end
6
+
7
+ class ShipmentNotFoundError < Error
8
+ def initialize(shipment_number, *args)
9
+ super("Could not find shipment with number #{shipment_number}", *args)
10
+ end
11
+ end
12
+
13
+ class PaymentError < Error
14
+ def initialize(payment, *args)
15
+ super("Could not process payment #{payment.id}", *args)
16
+ end
17
+ end
18
+
19
+ class MissingTrackingNumberError < Error
20
+ def initialize(*args)
21
+ super("Tracking number is required", *args)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir["#{File.dirname(__FILE__)}/testing_support/factories/**"].each do |f|
4
+ load File.expand_path(f)
5
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module Shipstation
5
+ class ShipmentNotice
6
+ attr_reader :shipment_number, :shipment_tracking, :store
7
+
8
+ class << self
9
+ def from_payload(params, store:)
10
+ new(
11
+ # ShipStation's webhook param is named `order_number` but its value is the
12
+ # shipment number — it mirrors the <OrderNumber> field from the export XML.
13
+ shipment_number: params[:order_number],
14
+ shipment_tracking: params[:tracking_number],
15
+ store: store
16
+ )
17
+ end
18
+ end
19
+
20
+ def initialize(shipment_number:, shipment_tracking:, store:)
21
+ @shipment_number = shipment_number
22
+ @shipment_tracking = shipment_tracking
23
+ @store = store
24
+ end
25
+
26
+ def apply
27
+ raise ShipmentNotFoundError, shipment_number unless shipment
28
+
29
+ ::Spree::Shipment.transaction do
30
+ ship_shipment
31
+ end
32
+
33
+ shipment
34
+ end
35
+
36
+ private
37
+
38
+ def shipment
39
+ @shipment ||= store.shipments.find_by(number: shipment_number)
40
+ end
41
+
42
+ def ship_shipment
43
+ raise MissingTrackingNumberError if shipment_tracking.blank?
44
+
45
+ # Payment capture is performed synchronously, inside the webhook request and
46
+ # the surrounding transaction, so a capture failure aborts the ship and is
47
+ # reported back to ShipStation as an error (HTTP 400) rather than silently
48
+ # shipping an uncaptured order. ShipStation retries failed webhooks, so the
49
+ # operation is written to be safe to repeat: an already-shipped shipment is
50
+ # not re-shipped (see below) and capture only targets still-pending payments.
51
+ capture_pending_payments! if ::Spree::Config.auto_capture_on_dispatch
52
+
53
+ shipment.tracking = shipment_tracking
54
+ shipment.save!
55
+
56
+ shipment.ship! unless shipment.shipped?
57
+ end
58
+
59
+ def capture_pending_payments!
60
+ shipment.order.payments.pending.each do |payment|
61
+ payment.capture!
62
+ rescue ::Spree::Core::GatewayError
63
+ raise PaymentError.new(payment)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,9 @@
1
+ FactoryBot.define do
2
+ factory :shipstation_integration, class: Spree::Integrations::Shipstation do
3
+ active { true }
4
+ preferred_username { "myusernameisvalid23kd-ws" }
5
+ preferred_password { "aWc2yNoc27tLisYeT-J@4fjyDN6JZHj@2t-6YFXArFXNUsZBs-G-r" }
6
+
7
+ store { Spree::Store.default }
8
+ end
9
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module Shipstation
5
+ # Major version tracks Spree's major version: 5.x supports Spree 5.x.
6
+ VERSION = "5.0.0"
7
+
8
+ def self.version
9
+ Gem::Version.new(VERSION)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pagy"
4
+
5
+ require "spree_core"
6
+ require "spree_extension"
7
+
8
+ require "spree/shipstation/version"
9
+ require "spree/shipstation/engine"
10
+ require "spree/shipstation/errors"
11
+ require "spree/shipstation/shipment_notice"
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bundler auto-requires a gem by its *name*, so `gem "spree-shipstation"` in a host
4
+ # Gemfile issues `require "spree-shipstation"`. The gem's real entry point is
5
+ # `spree/shipstation` (matching the Spree::Shipstation namespace), so this shim keeps
6
+ # the default `Bundler.require` working without every consumer having to write
7
+ # `gem "spree-shipstation", require: "spree/shipstation"`.
8
+ require "spree/shipstation"